diff --git a/checks/locks/go.mod b/checks/locks/go.mod new file mode 100644 index 00000000..d76e7992 --- /dev/null +++ b/checks/locks/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/locks + +go 1.22 diff --git a/checks/locks/main.go b/checks/locks/main.go new file mode 100644 index 00000000..e0b5c582 --- /dev/null +++ b/checks/locks/main.go @@ -0,0 +1,105 @@ +// Command locks refuses a TypeScript module that names packages beyond the SDK in its package.json and commits no +// package-lock.json (novox/hq ADR 0469 §5, issue 1868): the Node build module installs a module's own packages only +// from its committed lock, so such a module is refused by the builder and could not be delivered. +// +// go run . +// +// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go +// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Finding is one module refused: it names Packages beyond the SDK and has no lock file. +type Finding struct { + Module string + Packages []string +} + +// Check reads every module under root/modules that builds a TypeScript artifact. +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"` + Build struct { + Artifacts []struct { + Language string `json:"language"` + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + typescript := false + for _, a := range m.Build.Artifacts { + typescript = typescript || a.Language == "typescript" + } + if !typescript { + continue + } + dir := filepath.Dir(path) + pkgRaw, err := os.ReadFile(filepath.Join(dir, "package.json")) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, err + } + var pkg struct { + Dependencies map[string]json.RawMessage `json:"dependencies"` + } + if err := json.Unmarshal(pkgRaw, &pkg); err != nil { + return nil, fmt.Errorf("%s: package.json: %w", m.Module, err) + } + var names []string + for n := range pkg.Dependencies { + if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") { + names = append(names, n) + } + } + if len(names) == 0 { + continue + } + if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil { + sort.Strings(names) + out = append(out, Finding{Module: m.Module, Packages: names}) + } + } + 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 names packages beyond the SDK (%s) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)\n", f.Module, strings.Join(f.Packages, ", ")) + } + if len(found) > 0 { + os.Exit(1) + } + fmt.Println("locks: every TypeScript module that names packages beyond the SDK commits its lock") +} diff --git a/checks/locks/main_test.go b/checks/locks/main_test.go new file mode 100644 index 00000000..958a706f --- /dev/null +++ b/checks/locks/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// module writes modules/ with a TypeScript artifact, the package.json given ("" for none) and, when lock, a lock. +func module(t *testing.T, root, name, language, pkg string, lock bool) { + t.Helper() + dir := filepath.Join(root, "modules", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + write := func(file, body string) { + if err := os.WriteFile(filepath.Join(dir, file), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("module.json", `{"module":"`+name+`","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"`+language+`","from":"index.ts"}]}}`) + if pkg != "" { + write("package.json", pkg) + } + if lock { + write("package-lock.json", "{}") + } +} + +const sdkOnly = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1"},"devDependencies":{"typescript":"^5","@types/node":"^22"}}` +const withPg = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","pg":"^8","@types/pg":"^8"}}` + +func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) { + root := t.TempDir() + module(t, root, "plain", "typescript", sdkOnly, false) + module(t, root, "none", "typescript", "", false) + got, err := Check(root) + if err != nil || len(got) != 0 { + t.Fatalf("%v %v", got, err) + } +} + +func TestAModuleNamingMorePackagesWithoutALockIsRefusedNamingThem(t *testing.T) { + root := t.TempDir() + module(t, root, "db", "typescript", withPg, false) + module(t, root, "db-locked", "typescript", withPg, true) + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + if want := []Finding{{Module: "db", Packages: []string{"pg"}}}; !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) { + root := t.TempDir() + module(t, root, "go", "go", withPg, false) + if got, err := Check(root); err != nil || len(got) != 0 { + t.Fatalf("%v %v", got, err) + } +} diff --git a/checks/owned/main.go b/checks/owned/main.go index 67762f06..6a2c7078 100644 --- a/checks/owned/main.go +++ b/checks/owned/main.go @@ -11,13 +11,23 @@ // another module, after ${dir:} is resolved within each module and both homes are read as one root. // Archives are not containers for this rule. This holds for ~/.ssh what checks/sshdir held (ADR 0415 §6). // -// go run . +// 5. a directory that is, lies inside or holds a directory a system package owns under /var/lib, or any other +// resource inside one (issue 1841: pacman's state directory, placed with "place": ".", was /var/lib/pacman, the +// package manager's own database directory). A placed directory is resolved as the mesh resolves it with the +// default data root: "." is /var/lib/, "mesh" /var/lib/mesh/, neither /var/lib//. +// The system directories are checks/owned/system, captured from a machine (pacman -Qo, /usr/lib/tmpfiles.d); +// checks/owned/system-known names the exceptions still to fix, by module, resource and issue. That list only +// shrinks: an entry that no longer collides is refused, and with -base one the trunk's copy does not hold. +// +// go run . [-base ] // // Exits 1 on a finding. package main import ( "encoding/json" + "errors" + "flag" "fmt" "os" "path" @@ -27,6 +37,12 @@ import ( "strings" ) +// The lists of rule 5, relative to the catalogue's root. +const ( + SystemList = "checks/owned/system" + SystemKnown = "checks/owned/system-known" +) + // Home is the one root both machine homes are read as. const Home = "${home}" @@ -43,9 +59,9 @@ type Finding struct { } type resource struct { - ID, Type, Path, Unknown string - Knows []string - HasKnows bool + ID, Type, Path, Unknown, Place string + Knows []string + HasKnows bool } type parsed struct { @@ -65,6 +81,7 @@ func load(file string) (*parsed, error) { ID string `json:"id"` Type string `json:"type"` Path string `json:"path"` + Place string `json:"place"` Unknown *string `json:"unknown"` Knows []string `json:"knows"` } `json:"resources"` @@ -81,7 +98,7 @@ func load(file string) (*parsed, error) { out.Module = filepath.Base(filepath.Dir(file)) } for _, r := range m.Resources { - x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Knows: r.Knows, HasKnows: r.Knows != nil} + x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Place: r.Place, Knows: r.Knows, HasKnows: r.Knows != nil} if r.Unknown != nil { x.Unknown = *r.Unknown if x.Unknown == "" { @@ -143,14 +160,23 @@ type declared struct { } // Check reads every manifest under root/modules and answers what it refuses. -func Check(root string) ([]Finding, error) { +func Check(root string, base []string) ([]Finding, error) { files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) if err != nil { return nil, err } sort.Strings(files) + system, err := readSystem(filepath.Join(root, SystemList)) + if err != nil { + return nil, err + } + known, err := readLines(filepath.Join(root, SystemKnown)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } var out []Finding var paths []declared + collided := map[string]bool{} type container struct{ module, path string } var containers []container for _, file := range files { @@ -170,6 +196,7 @@ func Check(root string) ([]Finding, error) { dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID } } + out = append(out, inSystem(m, system, known, collided)...) for _, r := range m.Resources { p := norm(r.Path, dirs) if p != "" { @@ -212,6 +239,28 @@ func Check(root string) ([]Finding, error) { } } } + for _, k := range known { + if !collided[k] { + module, id, _ := strings.Cut(k, " ") + id, _, _ = strings.Cut(id, " ") + out = append(out, Finding{5, module, id, fmt.Sprintf("is listed in %s, and no longer lies in a system "+ + "package's directory: take the line out, so the list only shrinks", SystemKnown)}) + } + } + if base != nil { + held := map[string]bool{} + for _, b := range base { + held[b] = true + } + for _, k := range known { + if !held[k] { + module, id, _ := strings.Cut(k, " ") + id, _, _ = strings.Cut(id, " ") + out = append(out, Finding{5, module, id, fmt.Sprintf("is a new line in %s, which the trunk's copy "+ + "does not hold: a module's directory is placed outside a system package's, never listed", SystemKnown)}) + } + } + } for _, d := range paths { for _, c := range containers { if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) { @@ -222,21 +271,140 @@ func Check(root string) ([]Finding, error) { return out, nil } -func main() { - root := "." - if len(os.Args) > 1 { - root = os.Args[1] +// systemDir is one directory a system package owns, and what says so. +type systemDir struct{ Path, Source string } + +// readLines is a list's lines, without comments and blank lines. +func readLines(file string) ([]string, error) { + raw, err := os.ReadFile(file) + if err != nil { + return nil, err } - found, err := Check(root) + var lines []string + for _, line := range strings.Split(string(raw), "\n") { + if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, "#") { + lines = append(lines, line) + } + } + return lines, nil +} + +// readSystem is the list of system directories: " : " per line. Absent or +// empty, the check fails: a list that says nothing would let every module through. +func readSystem(file string) ([]systemDir, error) { + lines, err := readLines(file) + if err != nil { + return nil, fmt.Errorf("rule 5 reads the system packages' directories from %s: %w", file, err) + } + if len(lines) == 0 { + return nil, fmt.Errorf("%s names no system package's directory, so rule 5 would pass everything", file) + } + var out []systemDir + for _, line := range lines { + p, source, _ := strings.Cut(line, " ") + if !strings.HasPrefix(p, "/var/lib/") || strings.TrimSpace(source) == "" { + return nil, fmt.Errorf("%s: %q is not \"/var/lib/ : \"", file, line) + } + out = append(out, systemDir{path.Clean(p), strings.TrimSpace(source)}) + } + return out, nil +} + +// placedAt is where the mesh places a pathless directory with the default data root (mesh-sdk catalogue dirsFor). +func placedAt(module string, r resource) string { + switch r.Place { + case ".": + return "/var/lib/" + module + case "mesh": + return "/var/lib/mesh/" + module + } + return "/var/lib/" + module + "/" + r.ID +} + +// inSystem is rule 5 for one module: each resource whose resolved path collides with a system package's directory, +// unless system-known lists it (then it is marked collided, so a stale line is found). +func inSystem(m *parsed, system []systemDir, known []string, collided map[string]bool) []Finding { + real := map[string]string{} + for _, r := range m.Resources { + if r.Type == "directory" && r.Path == "" && r.ID != "" { + real[r.ID] = placedAt(m.Module, r) + } + } + for _, r := range m.Resources { + if r.ID != "" && r.Path != "" { + real[r.ID] = r.Path + } + } + var out []Finding + for _, r := range m.Resources { + p := "" + if r.Path != "" { + p = norm(r.Path, real) + } else if r.Type == "directory" { + p = real[r.ID] + } + if !strings.HasPrefix(p, "/") { + continue + } + for _, s := range system { + inside := p == s.Path || strings.HasPrefix(p, s.Path+"/") + holds := r.Type == "directory" && strings.HasPrefix(s.Path, p+"/") + if !inside && !holds { + continue + } + listed := "" + for _, k := range known { + if strings.HasPrefix(k, m.Module+" "+r.ID+" ") || k == m.Module+" "+r.ID { + listed = k + } + } + if listed != "" { + collided[listed] = true + break + } + how := "is or lies inside" + if holds { + how = "holds" + } + out = append(out, Finding{5, m.Module, r.ID, fmt.Sprintf("resolves to %q, which %s %s, the directory of the "+ + "system package %s; place it under /var/lib/mesh/%s (\"place\": \"mesh\") or a path of its own", p, how, + s.Path, s.Source, m.Module)}) + break + } + } + return out +} + +func main() { + baseFile := flag.String("base", "", "the trunk's copy of "+SystemKnown+": a line it does not hold is refused") + flag.Parse() + root := "." + if flag.NArg() > 0 { + root = flag.Arg(0) + } + var base []string + if *baseFile != "" { + lines, err := readLines(*baseFile) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + base = append([]string{}, lines...) + } + found, err := Check(root, base) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } for _, f := range found { - fmt.Printf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8, issue 1024)\n", f.Module, f.ID, f.Why, f.Rule) + cite := "novox/hq ADR 0422 §8, issue 1024" + if f.Rule == 5 { + cite = "issue 1841" + } + fmt.Printf("%s: %s: %s (rule %d, %s)\n", f.Module, f.ID, f.Why, f.Rule, cite) } if len(found) > 0 { os.Exit(1) } - fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory") + fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory, or in a system package's") } diff --git a/checks/owned/main_test.go b/checks/owned/main_test.go index 6426244f..ebe3981a 100644 --- a/checks/owned/main_test.go +++ b/checks/owned/main_test.go @@ -20,7 +20,10 @@ func manifest(t *testing.T, root, module, body string) { func check(t *testing.T, root string) []Finding { t.Helper() - found, err := Check(root) + if _, err := os.Stat(filepath.Join(root, SystemList)); os.IsNotExist(err) { + lists(t, root, "/var/lib/pacman pacman: pacman -Qo\n", "") + } + found, err := Check(root, nil) if err != nil { t.Fatal(err) } @@ -35,6 +38,20 @@ func only(t *testing.T, found []Finding, rule int, module, id string) { } } +// lists writes the catalogue's list of system directories and its known exceptions. +func lists(t *testing.T, root, system, known string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, SystemList), []byte(system), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, SystemKnown), []byte(known), 0o644); err != nil { + t.Fatal(err) + } +} + func dir(id, path, rest string) string { return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}` } @@ -145,3 +162,87 @@ func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) { t.Fatalf("refused %+v", found) } } + +// 1841/AC18: a module's directory that resolves to a /var/lib directory a system package owns, lies inside one or +// holds one, is refused, whether placed (place ".", or by default beneath the module's own) or stated; a placed +// directory saying place "mesh" lies under /var/lib/mesh and passes (issue 1841: pacman's state was /var/lib/pacman). +func TestADirectoryOfASystemPackageIsRefused(t *testing.T) { + system := "/var/lib/pacman pacman: pacman -Qo\n/var/lib/systemd/coredump systemd: tmpfiles.d/systemd.conf\n" + for name, resources := range map[string][]string{ + "placed-own": {`{"id":"state","type":"directory","place":".","unknown":"program"}`}, + "placed-beneath": {`{"id":"coredump","type":"directory","unknown":"program"}`}, + "stated-inside": {dir("d", "/var/lib/pacman/keeper", `,"unknown":"program"`)}, + "stated-holding": {dir("d", "/var/lib/systemd", `,"unknown":"program"`)}, + "file-inside": {`{"id":"state","type":"directory","place":"mesh","unknown":"program"}`, + `{"id":"f","type":"file","path":"/var/lib/pacman/keeper.json"}`}, + } { + module := map[string]string{"placed-own": "pacman", "placed-beneath": "systemd"}[name] + if module == "" { + module = "keeper" + } + root := t.TempDir() + lists(t, root, system, "") + manifest(t, root, module, mod(module, resources...)) + found := check(t, root) + if len(found) != 1 || found[0].Rule != 5 || found[0].Module != module || + !strings.Contains(found[0].Why, "/var/lib/") || !strings.Contains(found[0].Why, "package") { + t.Errorf("%s: found %+v; want one finding of rule 5 for %s naming the path and the package", name, found, module) + } + } + root := t.TempDir() + lists(t, root, system, "") + manifest(t, root, "pacman", mod("pacman", + `{"id":"state","type":"directory","place":"mesh","unknown":"program"}`, + `{"id":"f","type":"file","path":"${dir:state}/keeper.json"}`, + dir("other", "/var/lib/pacman-other", `,"unknown":"program"`))) + if found := check(t, root); len(found) != 0 { + t.Fatalf("a directory under /var/lib/mesh/pacman was refused: %+v", found) + } +} + +// 1841/AC18: a known exception passes; one that no longer collides is refused, so the list only shrinks; one the trunk's +// copy does not hold is refused; and an absent list of system directories fails the check rather than passing it. +func TestKnownSystemExceptionsOnlyShrink(t *testing.T) { + system := "/var/lib/systemd systemd: tmpfiles.d/systemd.conf\n" + known := "# comment\nsystemd state issue 1873\n" + root := t.TempDir() + lists(t, root, system, known) + manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":".","unknown":"program"}`)) + if found := check(t, root); len(found) != 0 { + t.Fatalf("a known exception was refused: %+v", found) + } + if found, err := Check(root, []string{"systemd state issue 1873"}); err != nil || len(found) != 0 { + t.Fatalf("a known exception held by the trunk was refused: %+v %v", found, err) + } + if found, err := Check(root, []string{}); err != nil || len(found) != 1 || found[0].Rule != 5 { + t.Fatalf("an exception the trunk does not hold passed: %+v %v", found, err) + } + + manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":"mesh","unknown":"program"}`)) + only(t, check(t, root), 5, "systemd", "state") + + root = t.TempDir() + manifest(t, root, "a", mod("a")) + if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, SystemList), []byte("# nothing\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Check(root, nil); err == nil { + t.Fatal("an empty list of system directories passed") + } +} + +// 1841/AC18: the catalogue as it stands passes rule 5 with its own lists. +func TestTheCatalogueKeepsOutOfSystemDirectories(t *testing.T) { + found, err := Check("../..", nil) + if err != nil { + t.Fatal(err) + } + for _, f := range found { + if f.Rule == 5 { + t.Errorf("%s: %s: %s", f.Module, f.ID, f.Why) + } + } +} diff --git a/checks/owned/system b/checks/owned/system new file mode 100644 index 00000000..2a37130f --- /dev/null +++ b/checks/owned/system @@ -0,0 +1,23 @@ +# The directories under /var/lib that a system package owns (rule 5 of checks/owned, issue 1841). A module's +# directory, placed or stated, may not be one of these, lie inside one or hold one: the package's program and the +# package manager keep their own files there, and the node-engine would judge, chmod or chown the directory. +# +# One line per directory: " : ". Captured on the workstation on 2026-10-22, through +# the mesh: pacman's own database by `pacman -Qo` (pacman_owns), the rest from the `d`, `D` and `Q` lines of +# /usr/lib/tmpfiles.d (node-reads.search for "/var/lib/"). A directory found to belong to a package is added here. +/var/lib/pacman pacman: pacman -Qo /var/lib/pacman/ (pacman 7.1.0) +/var/lib/alsa alsa-utils: tmpfiles.d/alsa-utils.conf +/var/lib/colord colord: tmpfiles.d/colord.conf +/var/lib/containers podman: tmpfiles.d/podman.conf +/var/lib/dbus dbus: tmpfiles.d/dbus.conf +/var/lib/geoclue geoclue: tmpfiles.d/geoclue.conf +/var/lib/lastlog util-linux: tmpfiles.d/lastlog2.conf +/var/lib/libuuid util-linux: tmpfiles.d/uuidd.conf +/var/lib/machines systemd: tmpfiles.d/systemd-nspawn.conf +/var/lib/openvpn/chroot networkmanager-openvpn: tmpfiles.d/nm-openvpn-tmpfiles.conf +/var/lib/portables systemd: tmpfiles.d/portables.conf +/var/lib/postgres postgresql: tmpfiles.d/postgresql.conf +/var/lib/private systemd: tmpfiles.d/systemd.conf +/var/lib/rpcbind rpcbind: tmpfiles.d/rpcbind.conf +/var/lib/systemd systemd: tmpfiles.d/systemd.conf +/var/lib/tpm2-tss/system/keystore tpm2-tss: tmpfiles.d/tpm2-tss-fapi.conf diff --git a/checks/owned/system-known b/checks/owned/system-known new file mode 100644 index 00000000..465a9ba8 --- /dev/null +++ b/checks/owned/system-known @@ -0,0 +1,5 @@ +# Modules whose directory lies in a system package's directory today (rule 5 of checks/owned, issue 1841), each with +# the issue that moves it out: " issue ". This list only shrinks: a line whose module +# no longer collides is refused, and so is a line the trunk's copy does not hold. +postgres state issue 1873 +systemd state issue 1873 diff --git a/merge-check.sh b/merge-check.sh index 3fe55bd1..834220ae 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -48,7 +48,8 @@ # 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it. # 11. owned paths (novox/hq ADR 0422 §8, issue 1024; it holds ~/.ssh for ssh-client as ADR 0415 §6 asked): # every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out` -# only under a home; and no module declares a path inside a directory of another module. +# only under a home; no module declares a path inside a directory of another module; and none is, lies in or +# holds a directory a system package owns under /var/lib (checks/owned/system, issue 1841). # checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned. # 12. the image builder (novox/hq ADR 0487 §3, issue 1704): only modules/buildah provides rootless-image-build. # 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory. @@ -64,6 +65,8 @@ set -eu (cd checks/imagebuild && go test -count=1 . && go run . ../..) # The image's place (novox/hq ADR 0487 §1): every image builds from a Dockerfile in its own module's directory. (cd checks/imageplace && go test -count=1 . && go run . ../..) +# The Node lock files (novox/hq ADR 0469 §5, issue 1868): a TypeScript module that names packages beyond the SDK commits its lock. +(cd checks/locks && 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. @@ -71,8 +74,19 @@ set -eu # ~/.ssh (novox/hq ADR 0415 §6): only ssh-client may name a path under it. Main's merge check runs this line, so # checks/sshdir stays until main runs checks/owned; it goes under issue 1035. (cd checks/sshdir && go test -count=1 . && go run . ../..) -# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory. -(cd checks/owned && go test -count=1 . && go run . ../..) +# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory; +# none in a system package's directory under /var/lib (issue 1841), the known exceptions only shrinking: judged +# against the trunk's copy of checks/owned/system-known at the merge base; a trunk without one is the list's first copy. +owned_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/owned/system-known" 2>/dev/null; then + owned_base=$(mktemp) + git show "$merge_base:checks/owned/system-known" >"$owned_base" + fi +else + echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/owned/system-known is held to the list it was first written with" +fi +(cd checks/owned && go test -count=1 . && if [ -n "$owned_base" ]; then go run . -base "$owned_base" ../..; else go run . ../..; fi) # 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="" diff --git a/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go index 4e769c4c..fea70036 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go +++ b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "net" "os" @@ -170,20 +171,33 @@ func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T) 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") + // the older clipmenud: it holds the lock the way clipmenud does, until it is killed. It says "held" once it has the + // lock, so the test goes on only when the lock is held for certain: a holder that is slow to start (the checker + // runs thousands of tests at once) must not let the launcher find the lock free (issue 1814). + holder := exec.Command("flock", "-x", "-n", lock, "sh", "-c", "echo held; exec sleep 60") holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + held, err := holder.StdoutPipe() + if err != nil { + t.Fatal(err) + } 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 + said := make(chan string, 1) + go func() { + line, _ := bufio.NewReader(held).ReadString('\n') + said <- strings.TrimSpace(line) + }() + select { + case line := <-said: + if line != "held" { + t.Fatalf("the older clipmenud did not take the lock: %q", line) } - time.Sleep(50 * time.Millisecond) + case <-time.After(30 * time.Second): + t.Fatal("the older clipmenud did not take the lock within 30 s") } var out syncBuffer cmd := exec.Command("sh", file) diff --git a/modules/gitea/README.md b/modules/gitea/README.md index b9b8482c..e735d7bb 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -22,16 +22,18 @@ Every new head of an open pull request is announced as `pull.updated`, once, and minutes, or when the search fails. - **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every open pull request from the forge itself, with the statuses its base requires and the ones its head has. The - `mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or - `mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound + delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` (or hq's + `mesh/repo-check`) with none of them for ten minutes since it first saw it so is a delivery held past its bound (`delivery..stalled`, state `unannounced`), which the controller raises like any other (issue 347). Each of the head's statuses comes with its state and when it was set, so a head whose check started and never answered — a required check pending, or never set beside one that is — is said as a stalled line in the state `unanswered` once that has lasted the delivery table's bound for a check asked and not answered, an hour, counted from its first sight and again only when a check is asked anew (issue 438). - **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own - was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep - at the latest, and `mesh-controller.conditions` names none for it. + was one, and the announcer it fixes never saw it — is announced within a pass of the next sweep at the latest, its + `mesh/check` is the checker's to set, and `mesh-controller.conditions` names none for it. (Since issue 1301 this + module writes `mesh/merge-gate` only where a base still requires it, and none does once the forge requires + `mesh/check`.) - **A pull request or issue number that is not one is refused** before the forge is asked: a tool called with a misnamed argument sent `pulls/NaN`. @@ -154,15 +156,19 @@ listed repository: naming the description, and a `merge-check.sh` declaring `# mesh-check-toolchain: go` that vets and runs the unit tests, then the integration tests (`-tags integration`), of every Go module in the tree, and says so and passes on a tree with none. The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges), - requiring `mesh/repo-check` and `mesh/merge-gate`, and only then is the repository moved into the organisation. + requiring `mesh/check` (ADR 0447 §1, issue 1301), and only then is the repository moved into the organisation. There main's rule is read back and put right once more (the step `rule-after-move`): the forge drops from a merge list a user without access to the repository, and under the setup account `mesh-tools` has none; - one found still under the setup account is finished: the steps it lacks are done, main is never written twice; -- one in the organisation is only put right: main's rule narrowed, a missing one of the two statuses added and every - status already there kept. +- one in the organisation is only put right: main's rule narrowed, `mesh/check` required in place of + `mesh/merge-gate` and `mesh/repo-check`, and every other status already there kept. A main that requires + `mesh/repo-check` without `mesh/merge-gate` is judged by its own script alone (hq, ADR 0503 §5, until issue 1296) + and keeps it; `mesh/check` is never dropped. -A step the forge refuses stops that repository, and the others go on. A repository taken out of the value is never -asked about: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in +Every other repository of the organisation whose main requires `mesh/merge-gate` has the two old statuses swapped +for `mesh/check`, and only its statuses (issue 1301): the switch reaches every repository the merge gate judged, with +no list kept by hand. A step the forge refuses stops that repository, and the others go on. Beyond that swap, a +repository taken out of the value is never changed: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in `repositories-outcome.json`. **How a refusal is seen.** `repositories sync` is a run-once process, declared as the module's last resource, so diff --git a/modules/gitea/check.ts b/modules/gitea/check.ts new file mode 100644 index 00000000..dec047c3 --- /dev/null +++ b/modules/gitea/check.ts @@ -0,0 +1,188 @@ +// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1 +// before it; novox/hq issue 1301; ADR 0447 §1, ADR 0503 §3: main requires mesh/check alone). +// +// No tool writes mesh/check: a tool is told no caller, and the console every agent session speaks through (node-tools) +// invokes every tool, so a tool that wrote it could be called by any session to merge past the checker (the fault issue +// 293 closed for the two statuses before it). Nor does an event: nothing in the delivery plan orders this module before the +// forge bridge, and a durable consumer whose filters change keeps its deliver policy, so an event said before this module +// consumed it was lost. The checker keeps what it owes the forge in its state `owed` (ADR 0484 §2), which it announces +// readable (ADR 0495 §2-§3), and only the checker writes it. This module watches it: the watch hands over the latest value of +// every key when it starts, whenever that is, and then every change. Each key whose act is the status mesh/check is +// written on its head, once per revision; a revision older than one already written on the key is dropped. +// +// A read refused (the grant not yet composed) is said once and asked again on a bound until it holds. A forge that refuses +// a status is asked again on a bound, until it writes or a newer revision of the key comes. + +import type { CommitStatus } from "./client.js"; +import { bindUntilHeld, bindWait } from "./bind.js"; +import { checkName } from "./guard.js"; +import { CHECK_STATUS } from "./pulls.js"; + +/** The checker's state this module reads, and the act of its keys this module writes. */ +export const CHECKER_OWED = "mesh-delivery-checker.owed"; +export const CHECK_ACT = `status=${CHECK_STATUS}`; + +const states = new Set(["pending", "success", "error", "failure", "warning"]); + +/** What writing the check needs of the forge. */ +export interface CheckForge { + setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; +} + +/** One change of the checker's owed, as the SDK's state watch hands it. */ +export interface OwedChange { + key: string; + op: "put" | "delete"; + value?: unknown; + revision: number; + current?: boolean; +} + +/** A key part as mesh-sdk go/delivery EscapeName writes it, read back: `=` and two lowercase hex digits per byte. */ +function unescapeName(part: string): string { + if (!/^([-/_a-zA-Z0-9]|=[0-9a-f]{2})+$/.test(part)) throw new Error(`${JSON.stringify(part)} is not an escaped name`); + return Buffer.from(part.replace(/=([0-9a-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), "latin1").toString("utf8"); +} + +/** The pull request and head of the checker's owed key (mesh-sdk go/delivery CheckerOwedKey): + * `/...status=mesh/check`, owner and name escaped. */ +export function owedKeyOf(key: string): { owner: string; repo: string; number: number; sha: string } { + const parts = String(key ?? "").split("."); + if (parts.length !== 4) throw new Error(`${JSON.stringify(key)} is not an owed key, ...`); + const [repository, number, sha, act] = parts; + if (act !== CHECK_ACT) throw new Error(`${JSON.stringify(key)} owes ${JSON.stringify(act)}, not ${CHECK_ACT}`); + const m = /^([^/]+)\/([^/]+)$/.exec(repository); + if (!m) throw new Error(`${JSON.stringify(key)} names no /`); + if (!/^(0|[1-9][0-9]*)$/.test(number)) throw new Error(`${JSON.stringify(key)} names no pull request number`); + if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(key)} names no head's whole sha`); + return { owner: checkName("owner", unescapeName(m[1])), repo: checkName("name", unescapeName(m[2])), number: Number(number), sha }; +} + +/** The status an owed change asks for, checked as mesh-sdk go/delivery DecodeCheckerOwed checks it; or why it is not one. */ +export function checkStatusOf(change: OwedChange): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } { + if (change.op !== "put") throw new Error(`a ${change.op} owes nothing`); + const { owner, repo, sha } = owedKeyOf(change.key); + const revision = change.revision; + if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) { + throw new Error(`${JSON.stringify(revision)} is not a revision`); + } + const v = change.value; + if (!v || typeof v !== "object") throw new Error("the value is no owed act"); + const o = v as Record; + if (o.v !== 1) throw new Error(`the owed act's version ${JSON.stringify(o.v)} is not one this module reads`); + if (o.act !== "status" || o.context !== CHECK_STATUS) { + throw new Error(`the owed act is ${JSON.stringify(o.act)} ${JSON.stringify(o.context)}, not the status ${CHECK_STATUS}`); + } + const state = String(o.state ?? ""); + if (!states.has(state)) throw new Error(`${JSON.stringify(o.state)} is not a status the forge keeps`); + let description = String(o.text ?? "").replace(/\s+/g, " ").trim(); + if (description.length > 140) description = description.slice(0, 139) + "…"; + const target = typeof o.target === "string" && /^https?:\/\//.test(o.target) ? o.target : undefined; + const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description, + ...(target ? { target_url: target } : {}) }; + return { owner, repo, sha, revision, status }; +} + +/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */ +export class CheckStatuses { + /** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */ + private written = new Map(); + private seen = new Map(); + /** The turn each key's writes wait on, and the asks again still running. */ + private turns = new Map>(); + private again = new Set>(); + private forge: CheckForge; + private say: (line: string) => void; + private sleep: (ms: number) => Promise; + + constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l), + sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms))) { + this.forge = forge; + this.say = say; + this.sleep = sleep; + } + + /** Write the change's status: "written", or "dropped" for one that owes no mesh/check or is older than one written. A + * forge that refuses throws. */ + async apply(change: OwedChange): Promise<"written" | "dropped"> { + let asked; + try { + asked = checkStatusOf(change); + } catch (err) { + this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`); + return "dropped"; + } + const key = change.key; + const seen = this.seen.get(key); + if (seen === undefined || asked.revision > seen) this.remember(this.seen, key, asked.revision); + // One key's writes take turns, so two at once are weighed one after the other. + const before = this.turns.get(key) ?? Promise.resolve(); + const turn = before.then(async (): Promise<"written" | "dropped"> => { + const newest = this.written.get(key); + if (newest !== undefined && asked.revision < newest) { + this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`); + return "dropped"; + } + await this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status); + this.remember(this.written, key, asked.revision); + return "written"; + }); + const settled = turn.catch(() => {}); + this.turns.set(key, settled); + void settled.then(() => { if (this.turns.get(key) === settled) this.turns.delete(key); }); + return turn; + } + + /** What the watch hands over: written, or when the forge refuses, asked again on a bound in the background until it is + * written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses the change. */ + async take(change: OwedChange): Promise { + try { + await this.apply(change); + } catch (err) { + const run = this.askAgain(change, err); + this.again.add(run); + void run.finally(() => this.again.delete(run)); + } + } + + /** Resolves when no ask again is running. */ + async settled(): Promise { + while (this.again.size) await Promise.all([...this.again]); + } + + private async askAgain(change: OwedChange, first: unknown): Promise { + let err = first; + for (let attempt = 1; ; attempt++) { + const wait = bindWait(attempt); + this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`); + await this.sleep(wait); + const newest = this.seen.get(change.key); + if (newest !== undefined && newest > change.revision) return; + try { + await this.apply(change); + return; + } catch (e) { + err = e; + } + } + } + + private remember(map: Map, key: string, revision: number): void { + map.delete(key); + map.set(key, revision); + if (map.size > 4000) map.delete(map.keys().next().value as string); + } +} + +/** + * Watch the checker's owed with `watch` (the SDK's `state(CHECKER_OWED).watch`), handing each change to `checks`. A watch + * refused — the read not granted yet — is said once and asked again on a bound until it holds (bind.ts). Never rejects. + */ +export async function watchOwed(watch: (handler: (change: OwedChange) => Promise) => Promise, checks: CheckStatuses, + sleep?: (ms: number) => Promise, say: (line: string) => void = (l) => console.error(l)): Promise { + let refusals = 0; + await bindUntilHeld(CHECKER_OWED, () => watch((change) => checks.take(change)), sleep, (line) => { + if (/could not be bound/.test(line) && refusals++ > 0) return; + say(line); + }); +} diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 33afefe5..6a2ba438 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -533,7 +533,7 @@ export class GiteaClient { * **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages * run past a bound): the call fails naming the repository and the commit, never answering the directories it did * see, since a directory left out would be taken for one without a module. Reads only. */ - async commitTrees(owner: string, repo: string, sha: string): Promise<{ root: string; trees: Record }> { + async commitTrees(owner: string, repo: string, sha: string, paths?: Record): Promise<{ root: string; trees: Record; paths?: Record> }> { const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`; const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`); const entries: any[] = []; @@ -560,7 +560,22 @@ export class GiteaClient { if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`); trees[dir] = tree; } - return { root, trees }; + if (paths === undefined) return { root, trees }; + // **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a + // directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails + // naming both, and nothing is answered in part. + const objectOf = new Map(); + for (const e of entries) if (typeof e?.path === "string" && (e.type === "tree" || e.type === "blob")) objectOf.set(e.path, String(e.sha)); + const named: Record> = {}; + for (const [dir, list] of Object.entries(paths)) { + named[dir] = {}; + for (const p of list) { + const id = objectOf.get(dir === "" ? p : `${dir}/${p}`); + if (!id) throw new Error(`the path ${p} of ${dir === "" ? "the repository root" : dir} is not in ${owner}/${repo} at ${sha}`); + named[dir][p] = id; + } + } + return { root, trees, paths: named }; } /** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */ diff --git a/modules/gitea/cmd/repositories/check_test.go b/modules/gitea/cmd/repositories/check_test.go new file mode 100644 index 00000000..0a5dd7aa --- /dev/null +++ b/modules/gitea/cmd/repositories/check_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "strings" + "testing" +) + +// The tests of issue 1301 (ADR 0447 §1, §5; ADR 0503 §3, §5; lead decisions L4 and L11): main requires `mesh/check` +// alone in place of `mesh/merge-gate` and `mesh/repo-check`; a main that requires `mesh/repo-check` without +// `mesh/merge-gate` (hq) keeps it; every repository of the organisation whose main requires `mesh/merge-gate` is +// switched, listed in the value or not; no other status is dropped. + +func narrowedRule(contexts ...any) map[string]any { + return map[string]any{ + "enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []any{}, + "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools}, + "enable_status_check": true, "status_check_contexts": contexts, + "block_admin_merge_override": true, + } +} + +// 1301-S1: a listed repository whose main requires the two old statuses is put right to `mesh/check` in their place, +// every other status kept; a second pass changes nothing. +func TestListedRepositorySwapsTheTwoStatusesForCheck(t *testing.T) { + f := newFake() + repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check", "other/x")}, "main") + s, _ := syncer(t, f) + o := run(t, s, Value{"mesh-delivery": {}}) + got := strs(repo.Rules[mainRule]["status_check_contexts"]) + if strings.Join(got, ",") != "other/x,mesh/check" { + t.Errorf("main requires %v, want other/x and mesh/check", got) + } + if r := result(o, "mesh-delivery"); r.Did != DidPutRight { + t.Errorf("outcome %+v, want put right", r) + } + before := len(f.writes()) + run(t, s, Value{"mesh-delivery": {}}) + if w := f.writes(); len(w) != before { + t.Errorf("a second pass wrote %v", w[before:]) + } +} + +// 1301-S1: a repository the program makes requires `mesh/check` alone from its first rule in the organisation on. +func TestNewRepositoryRequiresCheckAlone(t *testing.T) { + f := newFake() + s, _ := syncer(t, f) + run(t, s, Value{"fresh": {Description: "d"}}) + rule := f.repos["novox/fresh"].Rules[mainRule] + if got := strs(rule["status_check_contexts"]); rule["enable_status_check"] != true || strings.Join(got, ",") != CheckStatus { + t.Errorf("a new repository's main requires %v, want %s alone", got, CheckStatus) + } + if readme := f.repos["novox/fresh"].Files["README.md"]; !strings.Contains(readme, CheckStatus) || + strings.Contains(readme, "mesh/merge-gate") || strings.Contains(readme, "mesh/repo-check") { + t.Errorf("the README names the statuses of before: %q", readme) + } +} + +// 1301-S1: mesh/check is never dropped, and a rule that requires it beside a retired status loses the retired one. +func TestCheckIsKeptAndARetiredStatusBesideItGoes(t *testing.T) { + f := newFake() + both := f.add("novox/both", map[string]map[string]any{mainRule: narrowedRule("mesh/check", "mesh/repo-check")}, "main") + right := f.add("novox/right", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main") + s, _ := syncer(t, f) + o := run(t, s, Value{"both": {}, "right": {}}) + if got := strs(both.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { + t.Errorf("both requires %v, want %s alone", got, CheckStatus) + } + if got := strs(right.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { + t.Errorf("right requires %v", got) + } + if result(o, "right").Did != DidUnchanged || result(o, "both").Did != DidPutRight { + t.Errorf("outcome %+v", o) + } +} + +// 1301-S1: a listed repository whose main requires nothing is given mesh/check, not the old two. +func TestListedRuleRequiringNothingGetsCheck(t *testing.T) { + f := newFake() + none := f.add("novox/none", map[string]map[string]any{mainRule: {"enable_push": false}}, "main") + s, _ := syncer(t, f) + run(t, s, Value{"none": {}}) + if got := strs(none.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { + t.Errorf("none requires %v, want %s", got, CheckStatus) + } +} + +// 1301-S2 (ADR 0503 §5): a main that requires mesh/repo-check without mesh/merge-gate is judged by its own script +// alone (hq): it keeps mesh/repo-check and is not given mesh/check, listed or not. +func TestOwnCheckAloneIsKept(t *testing.T) { + f := newFake() + hq := f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main") + lab := f.add("novox/mesh-lab", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main") + s, _ := syncer(t, f) + o := run(t, s, Value{"hq": {}}) + for name, repo := range map[string]*fRepo{"hq": hq, "mesh-lab": lab} { + if got := strs(repo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != "mesh/repo-check" { + t.Errorf("%s requires %v, want mesh/repo-check alone", name, got) + } + } + if result(o, "hq").Did != DidUnchanged { + t.Errorf("hq outcome %+v, want unchanged", result(o, "hq")) + } + for _, w := range f.writes() { + t.Errorf("a repository judged by its own check alone was written: %s", w) + } +} + +// 1301-S3 (L4, L11): a repository the value does not list whose main requires mesh/merge-gate has the two old +// statuses swapped for mesh/check, and only its statuses: nothing else of its rule is sent. One that requires +// neither, or mesh/repo-check alone, is not written to. +func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) { + f := newFake() + catalog := f.add("novox/mesh-catalog", map[string]map[string]any{mainRule: { + "enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check"}, + }}, "main") + toolsGo := f.add("novox/mesh-tools-go", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main") + f.add("novox/plain", map[string]map[string]any{mainRule: {"enable_push": false}}, "main") + f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main") + f.add("novox/norule", nil, "main") + f.add("mesh-admin/elsewhere", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main") + s, _ := syncer(t, f) + o := run(t, s, Value{}) + + if got := strs(catalog.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { + t.Errorf("mesh-catalog requires %v, want %s", got, CheckStatus) + } + if catalog.Rules[mainRule]["enable_push"] != true { + t.Errorf("an unlisted repository's rule was narrowed beyond its statuses: %v", catalog.Rules[mainRule]) + } + if got := strs(toolsGo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { + t.Errorf("mesh-tools-go requires %v, want %s", got, CheckStatus) + } + want := []string{ + "PATCH /api/v1/repos/novox/mesh-catalog/branch_protections/main", + "PATCH /api/v1/repos/novox/mesh-tools-go/branch_protections/main", + } + if got := f.writes(); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("writes:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + for _, n := range []string{"mesh-catalog", "mesh-tools-go"} { + if r := result(o, n); r.Did != DidPutRight { + t.Errorf("%s outcome %+v, want put right", n, r) + } + } + for _, n := range []string{"plain", "hq", "norule", "elsewhere"} { + if r := result(o, n); r.Name != "" { + t.Errorf("%s is in the outcome though nothing was done: %+v", n, r) + } + } + before := len(f.writes()) + run(t, s, Value{}) + if w := f.writes(); len(w) != before { + t.Errorf("a second pass wrote %v", w[before:]) + } +} + +// 1301-S3: the organisation's repositories are read page by page, so one past the first page is switched too. +func TestUnlistedRepositoriesAreReadPastTheFirstPage(t *testing.T) { + f := newFake() + f.pageSize = 2 + for _, n := range []string{"a", "b", "c", "d", "e"} { + f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check")}, "main") + } + s, _ := syncer(t, f) + run(t, s, Value{}) + for _, n := range []string{"a", "b", "c", "d", "e"} { + if got := strs(f.repos["novox/"+n].Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { + t.Errorf("%s requires %v", n, got) + } + } +} + +// 1301-S3: a refused swap is said with the repository and its step, and the pass goes on to the next. +func TestRefusedSwapIsSaid(t *testing.T) { + f := newFake() + f.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main") + f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main") + f.refuse = func(method, path string) (int, string) { + if method == "PATCH" && strings.Contains(path, "/novox/a/") { + return 403, "no" + } + return 0, "" + } + s, _ := syncer(t, f) + o := run(t, s, Value{}) + if r := result(o, "a"); r.Did != DidRefused || r.Step != StepSwap { + t.Errorf("a: %+v, want refused at %s", r, StepSwap) + } + if r := result(o, "b"); r.Did != DidPutRight { + t.Errorf("b: %+v, want put right", r) + } +} + +// 1301-S3: the organisation's list refused is said, and the listed repositories are still done. +func TestOrganisationListRefusedIsSaid(t *testing.T) { + f := newFake() + f.refuse = func(method, path string) (int, string) { + if strings.HasPrefix(path, "/api/v1/orgs/") { + return 500, "down" + } + return 0, "" + } + s, _ := syncer(t, f) + o := run(t, s, Value{"x": {}}) + if result(o, "x").Did != DidMade { + t.Errorf("x: %+v", result(o, "x")) + } + refusedList := false + for _, r := range o.Refused() { + refusedList = refusedList || r.Step == StepList + } + if !refusedList { + t.Errorf("the refused list is not in the outcome: %+v", o) + } +} diff --git a/modules/gitea/cmd/repositories/fake_test.go b/modules/gitea/cmd/repositories/fake_test.go index f78be551..b265057d 100644 --- a/modules/gitea/cmd/repositories/fake_test.go +++ b/modules/gitea/cmd/repositories/fake_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "sync" "testing" @@ -43,6 +44,8 @@ type fakeForge struct { calls []string // "METHOD /path" // refuse answers a call with a status and a message when it returns a status above zero. refuse func(method, path string) (int, string) + // pageSize is how many repositories one page of the organisation's list holds; 50 when zero. + pageSize int } func newFake() *fakeForge { return &fakeForge{repos: map[string]*fRepo{}} } @@ -84,6 +87,7 @@ var ( branchPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/branches/([^/]+)$`) filesPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/contents$`) movePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/transfer$`) + orgRepos = regexp.MustCompile(`^/api/v1/orgs/([^/]+)/repos$`) ) func strs(v any) []string { @@ -167,6 +171,31 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case p == "/api/v1/version": reply(200, map[string]string{"version": "1.27"}) + case orgRepos.MatchString(p) && r.Method == http.MethodGet: + owner := orgRepos.FindStringSubmatch(p)[1] + var names []string + for full := range f.repos { + if o, n, _ := strings.Cut(full, "/"); o == owner { + names = append(names, n) + } + } + sort.Strings(names) + size := f.pageSize + if size == 0 { + size = 50 + } + if l, _ := strconv.Atoi(r.URL.Query().Get("limit")); l > 0 && l < size { + size = l + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + out := []map[string]any{} + for i := (page - 1) * size; i < len(names) && i < page*size; i++ { + out = append(out, map[string]any{"name": names[i], "full_name": owner + "/" + names[i]}) + } + reply(200, out) case p == "/api/v1/user/repos" && r.Method == http.MethodPost: name := fmt.Sprint(in["name"]) if f.repos[admin+"/"+name] != nil { @@ -312,12 +341,3 @@ func result(o Outcome, name string) Result { } return Result{} } - -func has(list []string, want string) bool { - for _, s := range list { - if s == want { - return true - } - } - return false -} diff --git a/modules/gitea/cmd/repositories/first.go b/modules/gitea/cmd/repositories/first.go index b1303299..1ae7365a 100644 --- a/modules/gitea/cmd/repositories/first.go +++ b/modules/gitea/cmd/repositories/first.go @@ -8,7 +8,7 @@ import "strings" const MergeCheck = `#!/bin/sh # mesh-check-toolchain: go # -# This repository's own check, mesh/repo-check (novox/hq ADR 0238 §4), written with its first commit (ADR 0428 §4): +# This repository's own check (novox/hq ADR 0238 §4), run by the checker within mesh/check (ADR 0447), written with its first commit (ADR 0428 §4): # every Go module in the tree is vetted, its unit tests run, and then its integration tests (-tags integration). # A tree with no Go module has nothing to test, and the check says so and passes. set -eu @@ -37,7 +37,7 @@ func FirstCommit(name string, r Repo) map[string]string { } readme := "# " + name + "\n\n" + purpose + "\n\n" + "Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: " + - "a change reaches it by a pull request whose `mesh/repo-check` (this repository's `merge-check.sh`) and " + - "`mesh/merge-gate` passed.\n" + "a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto " + + "main, this repository's `merge-check.sh` among its tests (novox/hq ADR 0447).\n" return map[string]string{"README.md": readme, "merge-check.sh": MergeCheck} } diff --git a/modules/gitea/cmd/repositories/main.go b/modules/gitea/cmd/repositories/main.go index 6a1a4c8e..48b707de 100644 --- a/modules/gitea/cmd/repositories/main.go +++ b/modules/gitea/cmd/repositories/main.go @@ -3,7 +3,8 @@ // again whenever the value changes: it reads the file the mesh renders from that value and, with the setup login, // makes every listed repository that does not exist under that login's own account, protects its main, writes // main's first commit, narrows main's rule and only then moves it into the organisation; one that exists only has -// its main rule put right. A repository the value does not list is never touched. It keeps what it did, per +// its main rule put right, requiring mesh/check in place of the two statuses it replaces (issue 1301). A repository +// the value does not list is touched only to swap those two for mesh/check when its main requires mesh/merge-gate. It keeps what it did, per // repository, in the outcome file, says each change on stdout in one line and never a secret, and exits 1 when a // step was refused, its last stderr lines naming each (outcome.go Report): the node-engine's gate on a run-once // step (ADR 0136) reports it and runs it again on every apply until it passes. diff --git a/modules/gitea/cmd/repositories/override_test.go b/modules/gitea/cmd/repositories/override_test.go index b26f520d..8dc5d7bc 100644 --- a/modules/gitea/cmd/repositories/override_test.go +++ b/modules/gitea/cmd/repositories/override_test.go @@ -18,7 +18,7 @@ func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) { } // 1141/AC2: a narrowed main rule that still lets an administrator override is not right: it is put right, its -// statuses kept, and a second pass changes nothing. +// statuses kept (the two old ones as mesh/check, issue 1301), and a second pass changes nothing. func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) { f := newFake() repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: { @@ -33,10 +33,8 @@ func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) { if rule["block_admin_merge_override"] != true { t.Errorf("the administrator's override is still allowed: %v", rule) } - for _, c := range []string{"mesh/merge-gate", "mesh/repo-check", "other/x"} { - if !has(strs(rule["status_check_contexts"]), c) { - t.Errorf("a status was dropped: %v", rule["status_check_contexts"]) - } + if c := strs(rule["status_check_contexts"]); strings.Join(c, ",") != "other/x,mesh/check" { + t.Errorf("statuses %v, want other/x kept and mesh/check for the two it replaces (issue 1301)", c) } if r := result(o, "mesh-delivery"); r.Did != DidPutRight { t.Errorf("outcome %+v, want put right", r) diff --git a/modules/gitea/cmd/repositories/repositories.go b/modules/gitea/cmd/repositories/repositories.go index 29985eeb..0bc95701 100644 --- a/modules/gitea/cmd/repositories/repositories.go +++ b/modules/gitea/cmd/repositories/repositories.go @@ -26,9 +26,47 @@ const ( MainBranch = "main" ) -// RequiredStatuses are what main requires in every listed repository from its first commit (ADR 0428 §5): the -// repository's own check and the module graph's gate. A status already on the rule is kept beside them. -var RequiredStatuses = []string{"mesh/merge-gate", "mesh/repo-check"} +// CheckStatus is the one status main requires (ADR 0447 §1, issue 1301): the checker's verdict on a pull request's +// head merged onto its trunk's head. It replaces GateStatus and OwnCheckStatus, and is never dropped. +const CheckStatus = "mesh/check" + +// GateStatus and OwnCheckStatus are the two statuses CheckStatus replaces: the controller's merge gate and the +// repository's own merge-check.sh (ADR 0238 §4, ADR 0428 §5 before ADR 0447 §1). +const ( + GateStatus = "mesh/merge-gate" + OwnCheckStatus = "mesh/repo-check" +) + +// wanted is what a main rule requiring have should require: have with GateStatus and OwnCheckStatus replaced by +// CheckStatus, every other status kept in its order (ADR 0447 §1). A rule requiring OwnCheckStatus with neither +// GateStatus nor CheckStatus is judged by its own script alone, as hq is until issue 1296 settles how hq is +// checked (ADR 0503 §5): the checker writes no status there (ADR 0484 §8), so it is left as it is. This reads the +// rule, not a list of names (lead decision L11 of R4e). +func wanted(have []string) []string { + if !has(have, GateStatus) && !has(have, CheckStatus) && has(have, OwnCheckStatus) { + return have + } + out := []string{} + for _, s := range have { + if s != GateStatus && s != OwnCheckStatus && s != CheckStatus { + out = append(out, s) + } + } + return append(out, CheckStatus) +} + +func has(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +func sameList(a, b []string) bool { + return strings.Join(a, "\x00") == strings.Join(b, "\x00") +} // The steps of one repository, as an outcome names a refused one (ADR 0428 §6). const ( @@ -42,6 +80,10 @@ const ( // StepRuleAfterMove reads main's rule back once the repository is in the organisation and puts it right: the // forge drops from a merge whitelist a user without access, and under the setup account the tools' have none. StepRuleAfterMove = "rule-after-move" + // StepList reads the organisation's repositories, for those the value does not list (issue 1301). + StepList = "list" + // StepSwap swaps the two old statuses for CheckStatus on a repository the value does not list (issue 1301). + StepSwap = "swap" ) // What a pass did with one repository. @@ -157,9 +199,73 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) { } out.Repositories = append(out.Repositories, r) } + for _, r := range s.switchUnlisted(ctx, v) { + if r.Did == DidRefused { + s.say("refused: %s/%s at the step %s: %s", s.Org, r.Name, r.Step, r.Said) + } else { + s.say("%s: %s/%s (%s in place of %s and %s)", r.Did, s.Org, r.Name, CheckStatus, GateStatus, OwnCheckStatus) + } + out.Repositories = append(out.Repositories, r) + } return out, nil } +// orgPage is how many repositories one read of the organisation's list asks for. +const orgPage = 50 + +// switchUnlisted swaps the two old statuses for CheckStatus on main's rule of every repository of the organisation +// the value does not list whose main requires GateStatus (issue 1301, lead decisions L4 and L11 of R4e): the +// repositories the merge gate judges are switched in the delivery that has the checker write mesh/check (ADR 0447 +// §5), with no list kept by hand. Only the statuses of a rule covering main are sent; nothing else of a rule the +// value does not hold is changed (ADR 0428 §7 for all of it but the statuses the mesh itself requires). A main that +// does not require GateStatus gets no call. It answers only what it did: a repository switched, or refused. +func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result { + var names []string + for page := 1; ; page++ { + var repos []struct { + Name string `json:"name"` + } + q := "?limit=" + fmt.Sprint(orgPage) + "&page=" + fmt.Sprint(page) + if err := s.Forge.GetJSON(ctx, "/api/v1/orgs/"+url.PathEscape(s.Org)+"/repos"+q, &repos); err != nil { + return []Result{refused("(the organisation's repositories)", StepList, err)} + } + for _, r := range repos { + if _, listed := v[r.Name]; !listed && r.Name != "" { + names = append(names, r.Name) + } + } + if len(repos) == 0 || page > 1000 { + break + } + } + sort.Strings(names) + var out []Result + for _, n := range names { + rules, err := s.rules(ctx, s.Org, n) + if err != nil { + out = append(out, refused(n, StepRead, err)) + continue + } + var did *Result + for _, r := range rules { + if ok, _ := path.Match(ruleName(r), MainBranch); !ok || !has(required(r), GateStatus) { + continue + } + body := map[string]any{"enable_status_check": true, "status_check_contexts": wanted(required(r))} + if err := s.Forge.SendJSON(ctx, http.MethodPatch, + repoAPI(s.Org, n)+"/branch_protections/"+url.PathEscape(ruleName(r)), body, nil); err != nil { + did = &Result{Name: n, Did: DidRefused, Step: StepSwap, Said: err.Error()} + break + } + did = &Result{Name: n, Did: DidPutRight} + } + if did != nil { + out = append(out, *did) + } + } + return out +} + // waitForForge is cmd/agent-teams' (teams.go waitForForge): the forge starting with the module answers late. func (s *Syncer) waitForForge(ctx context.Context) error { deadline := time.Now().Add(s.WaitFor) @@ -309,7 +415,7 @@ func strList(v any) []string { return out } -// statuses is what a rule requires, with RequiredStatuses added and none dropped. +// statuses is what the rules require together, as wanted makes it. func statuses(rules ...rule) []string { seen := map[string]bool{} var out []string @@ -320,16 +426,19 @@ func statuses(rules ...rule) []string { } } for _, r := range rules { - if r != nil && r["enable_status_check"] == true { - for _, s := range strList(r["status_check_contexts"]) { - add(s) - } + for _, s := range required(r) { + add(s) } } - for _, s := range RequiredStatuses { - add(s) + return wanted(out) +} + +// required is what a rule requires: its statuses when it checks them, else none. +func required(r rule) []string { + if r == nil || r["enable_status_check"] != true { + return nil } - return out + return strList(r["status_check_contexts"]) } // narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges, @@ -344,7 +453,7 @@ func narrowed(r rule, toolsAccount string, also ...rule) map[string]any { } } -// isRight says whether a rule already is narrowed and requires both statuses. +// isRight says whether a rule already is narrowed and requires what wanted makes of it. func isRight(r rule, toolsAccount string) bool { if r["enable_push"] != false || r["enable_push_whitelist"] == true || r["enable_merge_whitelist"] != true { return false @@ -352,26 +461,15 @@ func isRight(r rule, toolsAccount string) bool { if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount { return false } - return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"])) && + have := strList(r["status_check_contexts"]) + return r["enable_status_check"] == true && len(have) > 0 && sameList(have, wanted(have)) && r["block_admin_merge_override"] == true } -func hasAll(have []string) bool { - for _, want := range RequiredStatuses { - found := false - for _, h := range have { - found = found || h == want - } - if !found { - return false - } - } - return true -} - -// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires. +// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires but +// the two CheckStatus replaces (ADR 0447 §1). // A repository with no rule named main gets one, carrying what a glob rule covering main asks (client.ts -// ensureMainMerges, issue 948). It never takes a status away and never unprotects (ADR 0428 §5, §7). +// ensureMainMerges, issue 948). It takes away no other status and never unprotects (ADR 0428 §5, §7). func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) { rules, err := s.rules(ctx, owner, name) if err != nil { diff --git a/modules/gitea/cmd/repositories/repositories_test.go b/modules/gitea/cmd/repositories/repositories_test.go index 90592f35..9d551ab2 100644 --- a/modules/gitea/cmd/repositories/repositories_test.go +++ b/modules/gitea/cmd/repositories/repositories_test.go @@ -17,8 +17,8 @@ import ( const mainRule = "main" // 1124/AC2: a listed repository that does not exist is made under the setup account, given main's rule letting that -// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/repo-check (and -// mesh/merge-gate, 1124/AC5), and only then moved into the organisation. Nothing reaches the organisation before the +// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/check (1124/AC5 +// as issue 1301 changes it), and only then moved into the organisation. Nothing reaches the organisation before the // narrowed rule, and nothing else is pushed to main. func TestNewRepositoryIsMadeInOrder(t *testing.T) { f := newFake() @@ -67,8 +67,8 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) { t.Errorf("main's merge whitelist is not the tools' account alone: %v", rule) } checks := strs(rule["status_check_contexts"]) - if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") { - t.Errorf("main does not require mesh/repo-check and mesh/merge-gate (1124/AC5): %v", rule) + if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus { + t.Errorf("main does not require mesh/check alone (1124/AC5, issue 1301): %v", rule) } if r := result(o, "mesh-delivery"); r.Did != DidMade || r.Step != "" { t.Errorf("outcome %+v, want made", r) @@ -87,7 +87,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) { // 1124/AC2: the forge drops from a merge whitelist a user without access to the repository, and under the setup // account the tools' account has none; so once the repository is in the organisation its main rule is read back and -// put right: the tools' account the only one that merges, both statuses required. +// put right: the tools' account the only one that merges, mesh/check required. func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) { f := newFake() s, _ := syncer(t, f) @@ -104,8 +104,8 @@ func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) { t.Errorf("after the move main takes a push: %v", rule) } checks := strs(rule["status_check_contexts"]) - if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") { - t.Errorf("after the move main does not require both statuses: %v", rule) + if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus { + t.Errorf("after the move main does not require mesh/check alone: %v", rule) } if r := result(o, "mesh-delivery"); r.Did != DidMade { t.Errorf("outcome %+v, want made", r) @@ -136,7 +136,8 @@ func TestAdminRuleLetsTheSetupLoginAlonePush(t *testing.T) { } // 1124/AC3: a listed repository that exists in the organisation is not made again, only its main rule put right; -// a rule still letting the setup login push is narrowed, its statuses kept (1124/AC5). +// a rule still letting the setup login push is narrowed, its statuses kept but the two mesh/check replaces (1124/AC5, +// issue 1301). func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) { f := newFake() hq := f.add("novox/hq", map[string]map[string]any{mainRule: { @@ -157,14 +158,11 @@ func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) { t.Errorf("hq's rule not narrowed: %v", rule) } checks := strs(rule["status_check_contexts"]) - for _, c := range []string{"other/x", "mesh/merge-gate", "mesh/repo-check"} { - if !has(checks, c) { - t.Errorf("hq's rule lacks %s: %v", c, checks) - } + if strings.Join(checks, ",") != "other/x,mesh/check" { + t.Errorf("hq's rule requires %v, want other/x and mesh/check", checks) } made := bare.Rules[mainRule] - if made == nil || made["enable_push"] != false || !has(strs(made["status_check_contexts"]), "mesh/repo-check") || - !has(strs(made["status_check_contexts"]), "mesh/merge-gate") { + if made == nil || made["enable_push"] != false || strings.Join(strs(made["status_check_contexts"]), ",") != CheckStatus { t.Errorf("bare's rule: %v", made) } if hq.Commits != 0 || bare.Commits != 0 { @@ -214,8 +212,8 @@ func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) { } } -// 1124/AC5: putting a rule right adds a missing one of mesh/repo-check and mesh/merge-gate and keeps every status -// already there; no pass drops one. +// 1124/AC5 as issue 1301 changes it: putting a rule right adds mesh/check when the rule requires mesh/merge-gate, +// and keeps every other status there; a rule requiring mesh/repo-check alone keeps it (ADR 0503 §5). func TestStatusesAreAddedAndNeverDropped(t *testing.T) { f := newFake() only := f.add("novox/only", map[string]map[string]any{mainRule: { @@ -224,15 +222,15 @@ func TestStatusesAreAddedAndNeverDropped(t *testing.T) { }}, "main") more := f.add("novox/more", map[string]map[string]any{mainRule: { "enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools}, - "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "mesh/delivery"}, + "enable_status_check": true, "status_check_contexts": []any{"mesh/delivery", "mesh/check"}, "block_admin_merge_override": true, // right since issue 1141 }}, "main") s, _ := syncer(t, f) o := run(t, s, Value{"only": {}, "more": {}}) - if c := strs(only.Rules[mainRule]["status_check_contexts"]); !has(c, "mesh/merge-gate") || !has(c, "mesh/repo-check") { + if c := strs(only.Rules[mainRule]["status_check_contexts"]); strings.Join(c, ",") != "mesh/repo-check" { t.Errorf("only: %v", c) } - if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 3 || !has(c, "mesh/delivery") { + if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 2 || !has(c, "mesh/delivery") { t.Errorf("more lost a status: %v", c) } for _, w := range f.writes() { @@ -314,7 +312,8 @@ func TestRefusedStepStopsThatRepositoryOnly(t *testing.T) { } } -// 1124/AC7: a repository taken out of the value gets no call at all. +// 1124/AC7: a repository taken out of the value is not changed: its rules are read (issue 1301 switches one whose +// main requires mesh/merge-gate), nothing is written, and one still under the setup account is not asked about. func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) { f := newFake() f.add("novox/old", map[string]map[string]any{mainRule: {"enable_push": true}}, "main") @@ -323,7 +322,7 @@ func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) { run(t, s, Value{}) run(t, s, Value{"new": {}}) for _, c := range f.all() { - if strings.Contains(c, "old") { + if strings.Contains(c, "older") || (strings.Contains(c, "old") && !strings.HasPrefix(c, "GET ")) { t.Errorf("a repository out of the value was asked: %s", c) } } diff --git a/modules/gitea/delivery.ts b/modules/gitea/delivery.ts index 43277a67..050f6067 100644 --- a/modules/gitea/delivery.ts +++ b/modules/gitea/delivery.ts @@ -16,6 +16,9 @@ import { promisify } from "node:util"; import type { ToolDefinition } from "@novox/mesh-sdk/tools"; import type { CommitStatus } from "./client.js"; import { checkName } from "./guard.js"; +import { CHECK_STATUS } from "./pulls.js"; + +export { CHECK_STATUS }; /** How a command is run: docker on the forge's machine, or a test's. */ export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>; @@ -91,11 +94,18 @@ const states = new Set(["pending", "success", "error", "failure", "warning"]); /** The merge check's contexts (pulls.ts): a branch's protection may require them. */ const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]); + /** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a * bounded description. */ export function deliveryStatus(context: string, state: string, description: string, target?: string) { if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`); if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`); + // mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any + // state, as issue 293 closed for the two before it. No tool writes it, since a tool is told no caller (R4e finding K1): + // check.ts writes it from the checker's owed state (ADR 0521). + if (context === CHECK_STATUS) { + throw new Error(`${CHECK_STATUS} is the checker's status: it is written from mesh-delivery-checker.owed alone, never by a tool`); + } // The merge check's statuses are its verdict's, set when the controller says it, and a branch may require // them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq // issue 293). Pending is no verdict: it is what this holder says on every new head until the verdict comes, @@ -126,7 +136,7 @@ export interface StatusWriters { export function commitStatusTool(writers: StatusWriters): ToolDefinition { return { name: "gitea_commit_status", - description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.", + description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: no tool writes it: it is written from the checker's own state. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, diff --git a/modules/gitea/gate.ts b/modules/gitea/gate.ts index f92510bf..ca10290c 100644 --- a/modules/gitea/gate.ts +++ b/modules/gitea/gate.ts @@ -212,7 +212,8 @@ export class GateWriter { let since = h.pendingAt; if (since === undefined) { const gate = await this.forge.gateOf(owner, repo, sha); - if (gate && gate.state !== "pending") return; + // No gate on the forge: none was written (its base does not require it, issue 1301), and nothing is said. + if (!gate || gate.state !== "pending") return; since = gate?.updated_at ? Date.parse(gate.updated_at) : undefined; } if (since !== undefined && at.getTime() < since - CLOCK_SLACK_MS) return; diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index 35087244..99c83de0 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -3,7 +3,7 @@ module gitea go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum index 226d88cc..db1ea18c 100644 --- a/modules/gitea/go.sum +++ b/modules/gitea/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= 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= diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 601290e6..fe80595e 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -12,7 +12,13 @@ // Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head // commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and // `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment -// saying why when the gate is not a pass or the repository's own check failed. +// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base +// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere but hq, whose main +// requires `mesh/repo-check` alone (ADR 0503 §5). +// +// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and +// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands +// over the latest value of every key whenever this module starts. // // Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of, // which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate. @@ -38,12 +44,15 @@ import { emit, on } from "@novox/mesh-sdk/events"; import { registerModuleTools } from "@novox/mesh-sdk/tools"; +import { statesAnnouncement } from "./kept.js"; import { commitStatusTool } from "./delivery.js"; +import { CHECKER_OWED, CheckStatuses, watchOwed, type OwedChange } from "./check.js"; +import { state } from "@novox/mesh-sdk/state"; import { announce, keepWatermark, readWatermark } from "./announce.js"; import { mergeTool } from "./merge.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; -import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, statusesFor, type Announced, type Checked, - repoCheckFacts, reposToLook } from "./pulls.js"; +import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained, + requiredOf, statusesFor, writesGate, type Announced, type Checked, 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, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js"; @@ -247,8 +256,11 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr // 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); + // where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). Only where the base + // still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's, and hq's requires + // mesh/repo-check alone; a rule that cannot be read is said pending, as before. + const rule = await client.appliedProtection(repo.owner, repo.name, String(pull.base || "main")).catch(() => undefined); + if (writesGate(requiredOf(rule))) await gates.pending(repo.owner, repo.name, sha); return true; } type OpenOf = Awaited>[number]; @@ -257,7 +269,7 @@ type OpenOf = Awaited>[number]; async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise { try { const checks = await client.headChecks(repo.owner, repo.name, String(pull.head_sha)); - return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks; + return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks || CHECK_STATUS in checks; } catch { return false; // the forge cannot say: announced, and the bus's message id collapses it if it was } @@ -394,16 +406,20 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis // (novox/hq ADR 0323 §4): a check asked before the trunk moved. It is said so, pending with the reason, in place of // its pass or failure; a trunk that cannot be read lets the verdict stand, and the merge tool compares the commits. const trunk = base ?? c.plan?.base; + // Only the statuses the base still requires are written (issue 1301): on hq, mesh/repo-check alone. + const required = trunk ? requiredOf(await client.appliedProtection(c.owner, c.repo, trunk).catch(() => undefined)) : undefined; + const kept = maintained(required); if (c.trunk && trunk) { const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined); if (head && !sameCommit(c.trunk, head)) { const description = outOfDateWords(c.trunk, head); - for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]) { + for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])] + .filter((x) => kept.includes(x))) { await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description, ...(target ? { target_url: target } : {}) }); } // Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date. - const said = commentFor(c, await repoCheckFacts(client, c, trunk)); + const said = commentFor(c, await repoCheckFacts(client, c, trunk), required); if (c.number && !composedGate(c)) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description)); console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` + `${description}`); @@ -412,10 +428,10 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis } const facts = await repoCheckFacts(client, c, base ?? c.plan?.base); const gateNow = composedGate(c) ? (await client.headChecks(c.owner, c.repo, c.commit).catch(() => ({} as Record)))[CHECK_CONTEXT]?.description : undefined; - for (const status of statusesFor(c, facts, gateNow)) { + for (const status of statusesFor(c, facts, gateNow, required)) { await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status); } - const comment = composedGate(c) ? null : commentFor(c, facts); + const comment = composedGate(c) ? null : commentFor(c, facts, required); if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment); console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`); } @@ -465,6 +481,11 @@ const gates = new GateWriter({ headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha), }); +// The one writer of mesh/check, from the checker's owed (check.ts; ADR 0521). +const checks = new CheckStatuses({ + setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), +}); + // **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. @@ -473,6 +494,10 @@ const gates = new GateWriter({ const pace = new Pace(); if (!gitea) pace.unheard("the forge is not watched: no way to a token", false); +// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2). +// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852). +await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE)); + // 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), @@ -605,4 +630,7 @@ if (gitea) { })], ["node-build-agent.started", () => on("node-build-agent.started", buildStarted)], ], () => console.log("[gitea] watching for new repositories and merged pull requests")); + // The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again, + // apart from the binds above, so neither waits for the other. + void watchOwed((handler) => state(CHECKER_OWED).watch((change) => handler(change)), checks); } diff --git a/modules/gitea/kept.ts b/modules/gitea/kept.ts new file mode 100644 index 00000000..fccc5514 --- /dev/null +++ b/modules/gitea/kept.ts @@ -0,0 +1,20 @@ +// The states this module keeps on the bus and whether other modules may read them (novox/hq ADR 0495 clause 2; issue +// 1790): the module says it itself, from its code, as it starts (index.ts calls keeps with this list), and a reader is +// granted what it said (clause 3). A state is readable when another module's manifest reads it; none reads gitea's. +// test/kept.test.ts holds the list to the manifest's `state`. +export const KEPT: readonly { readonly name: string; readonly readable?: boolean }[] = [ + { name: "announced" }, + { name: "bound" }, +]; + +/** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1, + * the module, the machine when known, and the states sorted by name, `readable` only where true. It is said as the event + * `states`, which is the subject mesh.mod.gitea.event.states. gitea says it with the SDK's `emit` because the + * toolchain's SDK, which judges R4e's group, predates the SDK's `keeps` (0.1.15); switch to `keeps` once the toolchain's + * SDK is 0.1.15 or later (issue 1852). */ +export function statesAnnouncement(module: string, node: string | undefined) { + const states = [...KEPT] + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map((k) => (k.readable ? { name: k.name, readable: true } : { name: k.name })); + return { v: 1, module, ...(node ? { node } : {}), states }; +} diff --git a/modules/gitea/merge.ts b/modules/gitea/merge.ts index 1bac4620..5ba292f7 100644 --- a/modules/gitea/merge.ts +++ b/modules/gitea/merge.ts @@ -4,13 +4,18 @@ // when a verdict is judged against an earlier trunk commit than the trunk's head, the tool asks the head's check and // puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked. // Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain. +// +// **It judges on the status the base requires** (issue 1301; ADR 0447 §1, §4; ADR 0503 §5): mesh/check, the checker's, +// whose line names the trunk it judged ("pass on "); where a base does not require it, the controller's +// status it still requires (hq's mesh/repo-check), as before. A mesh/check of another trunk is refused and nothing is +// asked: the checker sets it out of date on the trunk's move and checks it again. import { announce } from "./announce.js"; import type { ToolDefinition } from "@novox/mesh-sdk/tools"; 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 { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js"; import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js"; import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js"; @@ -32,7 +37,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition return { name: "gitea_merge_pull_request", description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " + - "and every status its base branch's protection requires succeeded there (mesh/merge-gate, mesh/repo-check), " + + "and every status its base branch's protection requires succeeded there (mesh/check; hq's mesh/repo-check), " + "judged against the trunk's head as it is now, optionally deleting the source branch afterwards. Refused " + "otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " + "reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " + @@ -108,11 +113,33 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition // head makes — never again while one is on its way. The caller asks again at the reviewed head once it passes. const trunkHead = await gitea.branchHead(owner, repo, pull.base); const checks = await gitea.headChecks(owner, repo, pull.head_sha); - const at = verdictAtTrunk(checks, trunkHead); + const required = requiredOf(rule) ?? []; + if (required.includes(CHECK_STATUS)) { + // **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it + // is not current; nothing is written or asked — the checker checks a head again when its trunk moves. + const check = checks[CHECK_STATUS]; + const at = verdictAtTrunk(checks, trunkHead, CHECK_STATUS); + if (!check || check.state === "pending") { + throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ` + + `${check ? `pending (${check.description ?? ""})` : "not set yet"}; the checker answers it. Ask again at ` + + `${twelve(pull.head_sha)} once it passes against ${pull.base}'s head ${twelve(trunkHead)}. Nothing was merged`); + } + if (!at.current) { + throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} ` + + `${at.judged ? `was judged on trunk ${twelve(at.judged)}` : "names no trunk commit it was judged on"}, and ` + + `${pull.base} is now ${twelve(trunkHead)}. The checker checks it again against the trunk's head (novox/hq ` + + `ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`); + } + } + // Where the base still requires the controller's statuses (hq's mesh/repo-check; mesh/merge-gate only on a + // revert), the first it requires judges, as before (ADR 0323 §5). + const kept = maintained(required); + const judge = kept[0] ?? CHECK_CONTEXT; + const at = kept.length ? verdictAtTrunk(checks, trunkHead, judge) : { current: true, onItsWay: false }; if (!at.current) { // 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 siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.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}`); @@ -120,21 +147,22 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition // **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own // check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was // refused the same way for ever. The composed check is mesh-delivery's to ask; this says whose verdict is awaited. - if (waitsForComposed(checks[CHECK_CONTEXT])) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[CHECK_CONTEXT]?.description}; the ` + + if (waitsForComposed(checks[judge])) { + throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[judge]?.description}; the ` + `verdict that names its trunk commit is its group's composed check's, and mesh-delivery asks it, this tool asks no check ` + `(main is now ${now}). Ask again once the group's composed check has passed. Nothing was merged`); } if (at.onItsWay) { throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + - `${now}. Its check is already asked and not yet answered (${checks[CHECK_CONTEXT]?.description}), so it is not ` + + `${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` + `asked again; ${took}. Ask again at ${head} once both statuses pass: this tool never merges by itself ` + `(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`); } // Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An // announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather // than wait for ever on a check nobody asked. - const contexts = [CHECK_CONTEXT, ...(checks[REPO_CHECK_CONTEXT] ? [REPO_CHECK_CONTEXT] : [])]; + // The statuses the base requires of the controller's (issue 1301): hq's mesh/repo-check alone. + const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT])); // Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new // check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older. const say = async (description: string) => { @@ -167,9 +195,12 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition // **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)]; + // Under mesh/check (issue 1301) the group is the checker's one verdict on every member's head (ADR 0447 §2) and the + // Approve's heads above; a record the controller's verdicts left before the switch judges nothing there. + const heard = required.includes(CHECK_STATUS) ? undefined : 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)) { + if (!siblings.length && !required.includes(CHECK_STATUS) && + 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`); } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 10ad38ce..279529fb 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -172,7 +172,7 @@ }, "repositories": { "type": "object", - "description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/repo-check and mesh/merge-gate, and only then moved into the organisation; one that exists only has main's rule put right, its statuses kept. A repository taken out of the value is never touched.", + "description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.", "trusted": true, "default": {} } @@ -543,6 +543,7 @@ "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", - "mesh-build-image.keys" + "mesh-build-image.keys", + "mesh-delivery-checker.owed" ] } diff --git a/modules/gitea/protection.ts b/modules/gitea/protection.ts index 923d10c1..5336a827 100644 --- a/modules/gitea/protection.ts +++ b/modules/gitea/protection.ts @@ -1,6 +1,6 @@ -// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's merge check — -// `mesh/merge-gate`, the module graph's gate, and `mesh/repo-check`, the repository's own tests — before it -// may merge. Pure, so it is tested without a forge; the client does the asking (client.ts). +// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's check — `mesh/check`, +// the checker's verdict on the head merged onto its trunk (ADR 0447 §1; hq's `mesh/repo-check` until issue 1296) — +// before it may merge. Pure, so it is tested without a forge; the client does the asking (client.ts). import { TOOLS_ACCOUNT } from "./tools-account.js"; @@ -24,7 +24,7 @@ export interface BranchProtection { /** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's * default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */ export interface ProtectionWanted { - /** The statuses a pull request must have succeeded before it merges, e.g. mesh/merge-gate. Empty: none. */ + /** The statuses a pull request must have succeeded before it merges, e.g. mesh/check. Empty: none. */ statusChecks: string[]; /** Whether a person may push to the branch directly. */ push?: boolean; diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index cdd6a7ff..0c8fb96a 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -19,6 +19,34 @@ export const CHECK_CONTEXT = "mesh/merge-gate"; /** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */ export const REPO_CHECK_CONTEXT = "mesh/repo-check"; +/** The one status main requires (novox/hq ADR 0447 §1, issue 1301): the checker's verdict, written by this module from the + * checker's owed state (check.ts, ADR 0521). Its line names the head and the trunk commit it judged: "pass on …". */ +export const CHECK_STATUS = "mesh/check"; + +/** The statuses a branch's rule requires, from the rule the forge applies to it: none when it is unprotected or checks + * no status; undefined when the rule could not be read. */ +export function requiredOf(rule: { enable_status_check?: boolean; status_check_contexts?: string[] } | null | undefined): string[] | undefined { + if (rule === undefined) return undefined; + if (!rule || !rule.enable_status_check) return []; + return (rule.status_check_contexts ?? []).filter(Boolean); +} + +/** + * **The controller's statuses are written only where a base still requires them** (issue 1301; ADR 0503 §3, §5): of + * mesh/merge-gate and mesh/repo-check, those the base requires — hq's mesh/repo-check alone once the forge requires + * mesh/check everywhere else, so no mesh/merge-gate stands on a head that nothing will set. A base whose rule could not + * be read keeps both, as before: a status nothing requires blocks nothing, and one that is required is never left out. + */ +export function maintained(required: string[] | undefined): string[] { + const both = [CHECK_CONTEXT, REPO_CHECK_CONTEXT]; + return required === undefined ? both : both.filter((c) => required.includes(c)); +} + +/** Whether mesh/merge-gate is written on a head of this base (maintained). */ +export function writesGate(required: string[] | undefined): boolean { + return maintained(required).includes(CHECK_CONTEXT); +} + /** * The pending words a head's merge check is announced with (novox/hq issue 446): where the check is, and since when, * in the time the operator reads. They stood as "the mesh is mapping this head onto its module graph" for the whole @@ -257,8 +285,14 @@ export function composedGate(c: Checked): boolean { return c.gate?.verdict === "composed"; } -/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */ -export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] { +/** Every status a verdict sets: the gate's, and the repository's own check's when it was said; of them, only those the + * base requires (`required`, the base's rule: maintained). */ +export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string, required?: string[]): CommitStatus[] { + const kept = maintained(required); + return allStatusesFor(c, facts, gateNow).filter((s) => kept.includes(s.context)); +} + +function allStatusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] { if (composedGate(c)) { // A member whose gate the group's composed check judges (ADR 0403 §1): only its repository's check is its own to // set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it. @@ -292,11 +326,13 @@ function judgedFirst(c: Checked, status: CommitStatus): CommitStatus { * change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the * repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing, * is said by its statuses alone, not by a comment on every push. */ -export function commentFor(c: Checked, facts: RepoCheckFacts = {}): string | null { +export function commentFor(c: Checked, facts: RepoCheckFacts = {}, required?: string[]): string | null { const gate = c.gate ?? { verdict: c.verdict, summary: c.summary }; + // A base that no longer requires the gate (issue 1301) is told nothing of it: its verdict is the repository's own check. + const withGate = writesGate(required); const repo = c["repo-check"]; const wrong = repoWrong(repo, facts); - if (gate.verdict === "pass" && !wrong && !builds(c.plan)) return null; + if ((!withGate || gate.verdict === "pass") && !wrong && !builds(c.plan)) return null; const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""]; if (c.trunk) { // What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and what was read beside it. @@ -312,9 +348,11 @@ export function commentFor(c: Checked, facts: RepoCheckFacts = {}): string | nul } 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(", ")}` : "") + ")" : "")); + if (withGate) { + 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(", ")}` : "") + ")" : "")); + } if (repo) { const st = repoStatus(repo, facts); lines.push(`- \`${REPO_CHECK_CONTEXT}\`: ` + (repo.verdict === "warning" && facts.defined === false diff --git a/modules/gitea/test/bundle-import.test.ts b/modules/gitea/test/bundle-import.test.ts index d7b24e35..c971fe06 100644 --- a/modules/gitea/test/bundle-import.test.ts +++ b/modules/gitea/test/bundle-import.test.ts @@ -36,3 +36,76 @@ test("importing the bundle resolves while the broker keeps refusing the binds", clearTimeout(timer); assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`); }); + +// **The bundle writes mesh/check from the checker's owed state, and serves no tool that writes it** (ADR 0521; R4e round 2 +// finding R2-1; finding K1). The bundle is imported whole, as the runtime imports it, while its read of the checker's owed +// is refused (the grant not yet composed): the watch is asked again, and once granted it is handed the bucket as the bus +// keeps it, the latest revision of each key, then an older revision delivered late. The forge stand-in sees the latest +// written, and nothing after it. +test("a bundle whose read of the checker's owed is granted late writes the latest mesh/check, and serves no gitea_check_status", async () => { + const index = pathToFileURL(join(here, "..", "dist", "index.js")).href; + const sha = "0123456789abcdef0123456789abcdef01234567"; + const script = ` + const http = await import("node:http"); + const statuses = []; + const forge = http.createServer((req, res) => { + let b = ""; req.on("data", (d) => (b += d)); req.on("end", () => { + if (req.method === "POST" && /\\/statuses\\//.test(req.url)) statuses.push(req.url + " " + b); + res.setHeader("content-type", "application/json"); res.end(req.method === "GET" ? "[]" : "{}"); + }); + }); + await new Promise((r) => forge.listen(0, "127.0.0.1", r)); + process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port; + const bound = []; + const { useBroker } = await import("@novox/mesh-sdk/messaging"); + useBroker(() => ({ + subscribe: async (pattern) => { bound.push(pattern); return () => {}; }, + publish: async () => {}, request: async () => { throw new Error("no responders"); }, + handle: async () => () => {}, close: async () => {}, + })); + const key = "novox/mesh-sdk.12.${sha}.status=mesh/check"; + const owed = (state, text) => ({ v: 1, act: "status", context: "mesh/check", state, text, target: "https://view.example/c" }); + const watched = []; + let granted = 0; + const { useStateChannel } = await import("@novox/mesh-sdk/state"); + useStateChannel({ + ask: async () => null, + watch: async (params, deliver) => { + watched.push(params.state); + if (watched.length === 1) throw new Error('gitea keeps and reads no state called "mesh-delivery-checker.owed"'); + granted++; + // Two revisions were written while the read was refused; the bucket hands the latest, then a late older one. + await deliver({ state: params.state, key, op: "put", value: owed("success", "pass at 4"), revision: 4, current: true }); + await deliver({ state: params.state, key, op: "put", value: owed("pending", "pending at 3"), revision: 3, current: false }); + }, + }); + await import(${JSON.stringify(index)}); + const { collectTools } = await import("@novox/mesh-sdk/tools"); + const tools = collectTools().flatMap((r) => r.tools.map((t) => t.name)); + for (let i = 0; i < 300 && !(granted && statuses.length); i++) await new Promise((r) => setTimeout(r, 20)); + await new Promise((r) => setTimeout(r, 200)); + console.log(JSON.stringify({ tools, bound, watched, statuses })); + process.exit(0);`; + const env = { ...process.env, MESH_GITEA_TOKEN: "x" }; + for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE", "MESH_GITEA_URL"]) delete env[k]; + const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + let err = ""; + child.stdout.on("data", (d) => (out += d)); + child.stderr.on("data", (d) => (err += d)); + const ended = new Promise((r) => child.on("exit", () => r())); + const timer = setTimeout(() => child.kill("SIGKILL"), 15000); + await ended; + clearTimeout(timer); + const line = out.split("\n").find((l) => l.startsWith("{")); + assert.ok(line, `the bundle said nothing; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`); + const got = JSON.parse(line!) as { tools: string[]; bound: string[]; watched: string[]; statuses: string[] }; + assert.ok(got.tools.includes("gitea_commit_status"), `the bundle's tools: ${got.tools}`); + assert.ok(!got.tools.includes("gitea_check_status"), "no tool writes mesh/check"); + assert.ok(!got.bound.some((p) => p.includes("check.status")), `bound: ${got.bound}`); + assert.deepEqual(got.watched, ["mesh-delivery-checker.owed", "mesh-delivery-checker.owed"], "refused, then asked again"); + assert.equal(got.statuses.length, 1, `statuses written: ${got.statuses}`); + assert.match(got.statuses[0], new RegExp(`/repos/novox/mesh-sdk/statuses/${sha} .*"context":"mesh/check"`)); + assert.match(got.statuses[0], /"state":"success".*pass at 4/); + assert.match(err, /mesh-delivery-checker\.owed could not be bound/, "the refusal is said"); +}); diff --git a/modules/gitea/test/check-owed.test.ts b/modules/gitea/test/check-owed.test.ts new file mode 100644 index 00000000..14b93283 --- /dev/null +++ b/modules/gitea/test/check-owed.test.ts @@ -0,0 +1,135 @@ +// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1 +// before it). No tool writes mesh/check, since a tool is told no caller and node-tools invokes every tool. Nor does an event: +// nothing in the delivery plan orders this module before the forge bridge, and a durable consumer whose filters change keeps +// its deliver policy, so an event said before this module consumed it was lost. A watch of the checker's state hands over +// the latest value of every key when it starts, whenever that is. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const sha = "0123456789abcdef0123456789abcdef01234567"; +const key = (s = sha, repo = "novox/mesh-sdk", n = 12) => `${repo}.${n}.${s}.status=mesh/check`; +const owed = (over: Record = {}) => ({ v: 1, act: "status", context: "mesh/check", state: "success", + text: "pass 0123456789ab on aaaaaaaaaaaa", target: "https://view.example/checks/g.1", ...over }); +const put = (k: string, value: unknown, revision: number, current = false) => + ({ state: "mesh-delivery-checker.owed", key: k, op: "put" as const, value, revision, current }); + +function forge() { + const sent: string[] = []; + let failing = 0; + return { + sent, + fail: (n: number) => { failing = n; }, + setCommitStatus: async (o: string, r: string, s: string, st: any) => { + if (failing > 0) { failing--; throw new Error("Gitea API /repos/x/statuses: 502 bad gateway"); } + sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`); + }, + }; +} +const noSleep = async () => {}; + +test("the checker's owed is what this module reads, and it consumes no check.status", async () => { + const { CHECKER_OWED } = await import("../check.ts"); + assert.equal(CHECKER_OWED, "mesh-delivery-checker.owed"); + const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); + assert.ok(manifest.reads.includes(CHECKER_OWED), "gitea's manifest reads the checker's owed"); + assert.ok(!manifest.consumes.some((c: string) => c.includes("check.status")), "no check.status is consumed"); +}); + +test("an owed mesh/check writes the status on its head: its state, its line cut and its link", async () => { + const { CheckStatuses } = await import("../check.ts"); + const f = forge(); + const checks = new CheckStatuses(f, () => {}, noSleep); + assert.equal(await checks.apply(put(key(), owed(), 7, true)), "written"); + const other = sha.replace(/0/g, "f"); + assert.equal(await checks.apply(put(key(other, "novox/my=2erepo"), owed({ state: "pending", text: "y".repeat(300), target: "javascript:x" }), 8)), "written"); + assert.equal(f.sent[0], "novox/mesh-sdk@01234567 mesh/check success pass 0123456789ab on aaaaaaaaaaaa https://view.example/checks/g.1"); + assert.ok(f.sent[1].startsWith("novox/my.repo@f1234567 mesh/check pending yyyy"), f.sent[1]); + assert.ok(f.sent[1].length < 200, "the line is cut as every status's is"); + assert.ok(!f.sent[1].includes("javascript"), "a link that is no web page is not written"); +}); + +test("a key or value that is no owed mesh/check, and a delete, write nothing", async () => { + const { CheckStatuses } = await import("../check.ts"); + const f = forge(); + const checks = new CheckStatuses(f, () => {}, noSleep); + const cases = [ + put(key(sha.slice(0, 12)), owed(), 1), put(`novox/mesh-sdk.12.${sha}.status=mesh/merge-gate`, owed(), 1), + put(`novox/mesh-sdk.12.${sha}.view`, owed(), 1), put(`nokey`, owed(), 1), put(`../x/y.12.${sha}.status=mesh/check`, owed(), 1), + put(key(), owed({ context: "mesh/delivery" }), 1), put(key(), owed({ act: "view" }), 1), put(key(), owed({ state: "green" }), 1), + put(key(), owed({ v: 2 }), 1), put(key(), null, 1), put(key(), "text", 1), put(key(), owed(), -1), + { state: "mesh-delivery-checker.owed", key: key(), op: "delete" as const, revision: 2, current: false }, + ]; + for (const c of cases) assert.equal(await checks.apply(c as any), "dropped", JSON.stringify(c)); + assert.deepEqual(f.sent, []); +}); + +test("a revision older than one written on the key is dropped; the same one is written again", async () => { + const { CheckStatuses } = await import("../check.ts"); + const f = forge(); + const checks = new CheckStatuses(f, () => {}, noSleep); + await checks.apply(put(key(), owed({ state: "success" }), 10)); + assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 9)), "dropped", "a stale pending never hides the verdict"); + assert.equal(await checks.apply(put(key(), owed({ state: "success" }), 10)), "written"); + assert.equal(await checks.apply(put(key(sha.replace(/1/g, "2")), owed({ state: "pending" }), 3)), "written", "another key is its own"); + assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 11)), "written"); + assert.deepEqual(f.sent.map((s) => s.split(" ").slice(0, 3).join(" ")), ["novox/mesh-sdk@01234567 mesh/check success", + "novox/mesh-sdk@01234567 mesh/check success", "novox/mesh-sdk@02234567 mesh/check pending", "novox/mesh-sdk@01234567 mesh/check pending"]); +}); + +test("a forge that refuses is asked again until it writes, unless a newer revision of the key came meanwhile", async () => { + const { CheckStatuses } = await import("../check.ts"); + const f = forge(); + const said: string[] = []; + const waits: number[] = []; + const checks = new CheckStatuses(f, (l) => said.push(l), async (ms) => { waits.push(ms); }); + f.fail(2); + await checks.take(put(key(), owed({ state: "success" }), 5)); + await checks.settled(); + assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "written on the third ask"); + assert.deepEqual(waits, [2_000, 5_000]); + assert.match(said[0], /502.*asked again/); + // A refusal whose key moved on meanwhile is not asked again: the newer revision is written, the older never after it. + const g = forge(); + let release: () => void = () => {}; + const gate = new Promise((r) => { release = r; }); + const later = new CheckStatuses(g, () => {}, () => gate); + g.fail(1); + await later.take(put(key(), owed({ state: "pending" }), 6)); + await later.take(put(key(), owed({ state: "success" }), 7)); + release(); + await later.settled(); + assert.deepEqual(g.sent.map((s) => s.split(" ")[2]), ["success"]); +}); + +test("a watch refused at first is asked again, said once, and once granted writes the latest revision of each key", async () => { + const { CheckStatuses, watchOwed } = await import("../check.ts"); + const f = forge(); + const said: string[] = []; + const checks = new CheckStatuses(f, () => {}, noSleep); + // The checker's bucket as the bus keeps it: two revisions of one key written while the read was refused. + const kept = new Map(); + kept.set(key(), { value: owed({ state: "pending" }), revision: 3 }); + kept.set(key(), { value: owed({ state: "success" }), revision: 4 }); + let asked = 0; + const watch = async (handler: (c: any) => Promise) => { + asked++; + if (asked < 3) throw new Error("gitea keeps and reads no state called \"mesh-delivery-checker.owed\""); + for (const [k, e] of kept) await handler(put(k, e.value, e.revision, true)); + }; + await watchOwed(watch, checks, noSleep, (l) => said.push(l)); + await checks.settled(); + assert.equal(asked, 3); + assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "only the latest revision is written"); + assert.equal(said.filter((l) => /could not be bound/.test(l)).length, 1, `said once: ${said.join(" | ")}`); + assert.ok(said.some((l) => /bound after 3 attempts/.test(l)), said.join(" | ")); +}); + +test("no tool writes mesh/check: gitea_check_status is gone and gitea_commit_status refuses it", async () => { + const delivery = await import("../delivery.ts"); + assert.equal((delivery as Record).checkStatusTool, undefined, "delivery.ts serves no gitea_check_status"); + assert.throws(() => delivery.deliveryStatus("mesh/check", "success", "x"), /mesh-delivery-checker\.owed/); + const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8"); + assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status"); +}); diff --git a/modules/gitea/test/commit-trees.test.ts b/modules/gitea/test/commit-trees.test.ts index 51801d7d..e685a5c0 100644 --- a/modules/gitea/test/commit-trees.test.ts +++ b/modules/gitea/test/commit-trees.test.ts @@ -171,3 +171,53 @@ test("the root tree is git's hash of the root's own entries, never the listing's server.close(); } }); + +test("named paths: each asked path's git object id at the commit, a file's or a directory's, by directory (1758 S1, ADR 0499 §3)", async () => { + const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + const got = await client.commitTrees("novox", "hq", COMMIT, { "modules/a": ["main.go", "module.json"], "modules/b": ["c"], "": ["README.md", "modules/d"] }); + assert.deepEqual(got.paths, { + "modules/a": { "main.go": h("6"), "module.json": h("5") }, + "modules/b": { c: h("8") }, + "": { "README.md": h("1"), "modules/d": h("a") }, + }); + assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") }); + assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered"); + } finally { + server.close(); + } +}); + +test("a named path the listing lacks fails naming the directory and the path, and answers nothing", async () => { + const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + await assert.rejects(client.commitTrees("novox", "hq", COMMIT, { "modules/a": ["main.go", "gone.go"] }), (err: Error) => { + assert.match(err.message, /modules\/a/); + assert.match(err.message, /gone\.go/); + return true; + }); + for (const bad of [[""], ["../x"], ["/abs"], ["a//b"]]) { + await assert.rejects(client.commitTrees("novox", "hq", COMMIT, { "modules/a": bad }), /./, JSON.stringify(bad)); + } + } finally { + server.close(); + } +}); + +test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another shape before the forge is asked", async () => { + const { getGiteaTools } = await import("../tools/index.ts"); + const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!; + assert.ok("paths" in tool.input, "the tool names the argument"); + const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any); + assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } }); + const before = asked.length; + for (const paths of ["modules/a", ["main.go"], { "modules/a": "main.go" }, { "modules/a": [1] }]) { + await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths } as any), /paths/, JSON.stringify(paths)); + } + assert.equal(asked.length, before, "the forge was asked for a refused call"); + } finally { + server.close(); + } +}); diff --git a/modules/gitea/test/delivery.test.ts b/modules/gitea/test/delivery.test.ts index 623ec204..72466028 100644 --- a/modules/gitea/test/delivery.test.ts +++ b/modules/gitea/test/delivery.test.ts @@ -61,3 +61,21 @@ test("one view per pull request, found by its marker; only the mesh's statuses", assert.equal(deliveryStatus("mesh/repo-check", "pending", "checking again: main moved on").context, "mesh/repo-check"); assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined); }); + +// Issue 1301-S4 (ADR 0447 §1; R4e findings K1 and R2-1): main requires mesh/check, so no hand may set it. The status tool +// every caller may use refuses it in every state; no tool writes it at all: it is written from the checker's owed state +// (check.ts, test/check-owed.test.ts; ADR 0521). +test("mesh/check is refused by the status tool in every state", async () => { + const { commitStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts"); + assert.equal(CHECK_STATUS, "mesh/check"); + const sha = "0123456789abcdef0123456789abcdef01234567"; + const sent: string[] = []; + const setCommitStatus = async (o: string, r: string, s: string, st: any) => void sent.push(`${o}/${r}@${s} ${st.context}`); + const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } }; + for (const state of ["pending", "success", "failure", "error", "warning"]) { + assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/); + await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha, + context: "mesh/check", state, description: "by hand" }), /mesh-delivery-checker\.owed/); + } + assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check"); +}); diff --git a/modules/gitea/test/gate.test.ts b/modules/gitea/test/gate.test.ts index 4567a76f..82cea9ff 100644 --- a/modules/gitea/test/gate.test.ts +++ b/modules/gitea/test/gate.test.ts @@ -322,3 +322,13 @@ test("a repository as the mesh clones it is told from another", async () => { assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false); assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false); }); + +// Issue 1301: a build machine's start on a head that holds no mesh/merge-gate (its base requires none, so none was written) +// says nothing: no "building on" status appears on a main that does not require the gate. +test("a start on a head with no gate on the forge writes nothing", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + const gates = new GateWriter(forge, announcedAt); + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false); + assert.equal(forge.kept["mesh/merge-gate"], undefined); +}); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index c7605f3c..98281f29 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -568,6 +568,7 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud ["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }], ["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }], ["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }], + ["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }], ["gitea_commit_statuses", { ...repo, sha: ".." }], ["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }], ["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }], @@ -704,3 +705,53 @@ test("a group of two merges in one pass: the sibling's merge of the composed hea assert.equal(done.merged, true); assert.equal(state.emitted.length, 0, "no check was asked"); }); + +// **Issue 1301 (ADR 0447 §1, §4; ADR 0503 §3, §5): the merge tool judges on the status the base requires.** A main that +// requires mesh/check is judged by the checker's line ("pass on …"): it merges on a pass that names the +// trunk's head, and refuses one that names an older trunk, naming both, writing nothing and asking nothing — a re-check on +// a trunk's move is the checker's. No mesh/merge-gate is read or written there. +test("gitea_merge_pull_request judges a main requiring mesh/check on mesh/check alone, and asks nothing of it", async () => { + const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); + state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/check"] }]; + const line = (trunk: string, s = "success", words = "pass") => + [{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }]; + const before = state.emitted.length; + + state.statuses = line(MOVED); + await assert.rejects(merge(), (err: Error) => { + assert.match(err.message, /mesh\/check/); + assert.match(err.message, new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`)); + assert.match(err.message, /checker checks it again/i); + assert.match(err.message, /Nothing was merged/); + return true; + }); + state.statuses = line(TRUNK, "pending", "checking"); + await assert.rejects(merge(), /mesh\/check is pending/); + state.statuses = judgedAt(TRUNK); // the old two, current: not what this main requires + await assert.rejects(merge(), /mesh\/check/); + assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's"); + assert.equal(state.emitted.length, before, "no check is asked"); + assert.equal(state.merged, false); + + state.statuses = line(TRUNK); + assert.equal(((await merge()) as Record).merged, true); +}); + +// hq's main requires mesh/repo-check alone (ADR 0503 §5): judged on its words, and an out-of-date one is asked again on +// mesh/repo-check alone; no mesh/merge-gate is written on it. +test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on it, and writes no mesh/merge-gate", async () => { + const { keepSiblings, siblingsFile } = await import("../dist/siblings.js"); + keepSiblings(siblingsFile(stateDir), {}); // no sibling record left by the tests before + const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); + state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/repo-check"] }]; + state.statuses = [judgedAt(MOVED)[1]]; + const before = state.emitted.length; + await assert.rejects(merge(), /judged against trunk/); + assert.deepEqual(state.sent.filter((s) => s.path.includes("/statuses/")).map((s) => `${s.body.context} ${s.body.state}`), + ["mesh/repo-check pending"]); + assert.equal(state.emitted.length, before + 1, "its check is asked once"); + await assert.rejects(merge(), /already asked/); + state.sent = []; + state.statuses = [judgedAt(TRUNK)[1]]; + assert.equal(((await merge()) as Record).merged, true); +}); diff --git a/modules/gitea/test/kept.test.ts b/modules/gitea/test/kept.test.ts new file mode 100644 index 00000000..5702f8e1 --- /dev/null +++ b/modules/gitea/test/kept.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { readFileSync } from "node:fs"; + +// 1790: the states the module announces are the states its manifest declares, no more, no fewer; none is readable +// because no other module's manifest reads one of them. +test("the states the module announces are the states its manifest declares", async () => { + const { KEPT } = await import("../kept.ts"); + const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); + const declared = (manifest.state as (string | { name: string })[]).map((s) => (typeof s === "string" ? s : s.name)).sort(); + assert.deepEqual(KEPT.map((k) => k.name).sort(), declared); + assert.ok(KEPT.every((k) => !k.readable), "no other module's manifest reads a state of gitea"); +}); + +test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => { + const { statesAnnouncement } = await import("../kept.ts"); + assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound" }] }); + assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound" }] }); +}); + +test("index.ts says the announcement as the event `states` before the tools are served", () => { + const source = readFileSync(new URL("../index.ts", import.meta.url), "utf8"); + assert.match(source, /await emit\("states", statesAnnouncement\(/); + assert.ok(source.indexOf('await emit("states"') < source.indexOf('registerModuleTools("gitea"'), "announced before the tools are served"); +}); diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts index 3ebabf47..bb3d257b 100644 --- a/modules/gitea/test/pulls.test.ts +++ b/modules/gitea/test/pulls.test.ts @@ -192,3 +192,27 @@ test("a member's own verdict with a composed gate layer leaves the gate to the c // A verdict with an ordinary gate is as before. assert.equal(composedGate({ ...c, gate: { verdict: "pass", summary: "x" } }), false); }); + +// Issue 1301 (ADR 0503 §3, §5): gitea writes the controller's statuses only where the base still requires them. hq's main +// requires mesh/repo-check alone: its verdict sets mesh/repo-check and no mesh/merge-gate, and its comment names no gate. +test("a base's required statuses decide which of the controller's statuses are written", async () => { + const { maintained, writesGate, statusesFor, commentFor, CHECK_STATUS } = await import("../pulls.ts"); + assert.equal(CHECK_STATUS, "mesh/check"); + assert.deepEqual(maintained(undefined), ["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before"); + assert.deepEqual(maintained(["mesh/repo-check"]), ["mesh/repo-check"]); + assert.deepEqual(maintained(["mesh/check", "other/x"]), []); + assert.deepEqual(maintained([]), []); + assert.equal(writesGate(undefined), true); + assert.equal(writesGate(["mesh/repo-check"]), false); + assert.equal(writesGate(["mesh/check"]), false); + assert.equal(writesGate(["mesh/merge-gate", "mesh/repo-check"]), true); + const c = { owner: "novox", repo: "hq", number: 5, commit: "a".repeat(40), id: "x", verdict: "warning", summary: "no script", + trunk: "b".repeat(12), "repo-check": { verdict: "pass", summary: "ok" } } as any; + const hq = statusesFor(c, {}, undefined, ["mesh/repo-check"]); + assert.deepEqual(hq.map((s: any) => s.context), ["mesh/repo-check"]); + assert.match(hq[0].description, /merged into trunk bbbbbbbbbbbb/); + assert.deepEqual(statusesFor(c).map((s: any) => s.context), ["mesh/merge-gate", "mesh/repo-check"], "unread: as before"); + assert.deepEqual(statusesFor(c, {}, undefined, ["mesh/check"]), []); + const comment = commentFor(c, {}, ["mesh/repo-check"]); + assert.ok(comment === null || !comment.includes("mesh/merge-gate"), comment ?? ""); +}); diff --git a/modules/gitea/test/trunk.test.ts b/modules/gitea/test/trunk.test.ts index 3eb373a6..020af6bd 100644 --- a/modules/gitea/test/trunk.test.ts +++ b/modules/gitea/test/trunk.test.ts @@ -199,3 +199,21 @@ test("a release branch's move puts every open pull request into it out of date, assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success", description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false); }); + +// Issue 1301: a trunk's move puts out of date only the controller's statuses the base still requires; on hq that is +// mesh/repo-check alone, judged by its own words; on a main requiring mesh/check, none (the checker's, ADR 0447 §4). +test("a trunk's move writes only the statuses the base still requires", async () => { + const { outOfDateStatuses, trunkNamed } = await import("../trunk.ts"); + const old = "1".repeat(40), now = "2".repeat(40); + const repo = { state: "success", description: `head ${"a".repeat(12)} merged into trunk ${old.slice(0, 12)} — pass: y` }; + const gate = { state: "success", description: `head ${"a".repeat(12)} merged into trunk ${old.slice(0, 12)} — pass: x` }; + assert.deepEqual(outOfDateStatuses({ "mesh/repo-check": repo }, now, ["mesh/repo-check"]).map((s: any) => s.context), ["mesh/repo-check"]); + assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now, ["mesh/repo-check"]).map((s: any) => s.context), + ["mesh/repo-check"]); + assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now, ["mesh/check"]), []); + assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now).map((s: any) => s.context), + ["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before"); + // The checker's line names its trunk as " on ". + assert.equal(trunkNamed(`pass ${"a".repeat(12)} on ${old.slice(0, 12)} | unit 3 run`), old.slice(0, 12)); + assert.equal(trunkNamed("building on shanks since 10:00"), undefined); +}); diff --git a/modules/gitea/test/webhook.test.ts b/modules/gitea/test/webhook.test.ts index cf2450c7..e01ec286 100644 --- a/modules/gitea/test/webhook.test.ts +++ b/modules/gitea/test/webhook.test.ts @@ -350,3 +350,14 @@ test("a pull request's edit event carries its description, and no other pull req assert.equal(eventOf("pull_request", { action, pull_request: pull, repository }).body.body, undefined, action); } }); + +// Issue 1302: a status event carries the status's words, so the delivery gate reads mesh/check's line (the head and the trunk +// it judged) from the event itself. +test("a status event carries its context, state and words", async () => { + const { eventOf } = await import("../webhook.ts"); + const e = eventOf("status", { sha: "a".repeat(40), context: "mesh/check", state: "success", + description: "pass aaaaaaaaaaaa on bbbbbbbbbbbb | unit 3 run, 0 kept", repository: { name: "x", owner: { login: "novox" } } }); + assert.equal(e.name, "forge.status"); + assert.equal((e.body as any).context, "mesh/check"); + assert.equal((e.body as any).description, "pass aaaaaaaaaaaa on bbbbbbbbbbbb | unit 3 run, 0 kept"); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index d639a6b8..7100fdbc 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -60,6 +60,22 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] { })); } +/** The `paths` argument of gitea_commit_trees: {directory: [path relative to it]}, each path plain (no empty, `.` or `..` + * segment, not absolute); anything else is refused before the forge is asked. Absent is absent. */ +function namedPaths(raw: unknown): Record | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("paths is {directory: [path, ...]}"); + const out: Record = {}; + for (const [dir, list] of Object.entries(raw as Record)) { + if (!Array.isArray(list) || list.some((p) => typeof p !== "string")) throw new Error(`paths of ${dir || "the root"} is a list of paths`); + for (const p of list as string[]) { + if (p === "" || p.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`paths: ${JSON.stringify(p)} of ${dir || "the root"} is not a plain relative path`); + } + out[dir] = list as string[]; + } + return out; +} + export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { return namesChecked(allGiteaTools(gitea, admin)); } @@ -395,7 +411,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini // ---- Branch protection (novox/hq ADR 0237) ---- { name: "gitea_branch_protection_get", - description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/merge-gate), approvals, and whether an administrator may merge past them.", + description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/check), approvals, and whether an administrator may merge past them.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, @@ -413,12 +429,12 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, { name: "gitea_branch_protection_set", - description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.", + description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's checker sets `mesh/check` (ADR 0447), and the repositories program requires it on every main. Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, branch: { type: "string", description: "the branch to protect, e.g. main" }, - status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/merge-gate,mesh/repo-check; empty requires none" }, + status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/check; empty requires none" }, push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" }, block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" }, }, @@ -478,7 +494,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, { name: "gitea_commit_statuses", - description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.", + description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, @@ -525,18 +541,19 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, { name: "gitea_commit_trees", - description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.", + description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, sha: { type: "string", description: "the commit, its full 40-hex sha" }, + paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" }, }, run: async (args) => { const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); const sha = String(args.sha ?? "").trim(); if (!owner || !repo) throw new Error("name the repository: owner and repo"); if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha"); - return gitea.commitTrees(owner, repo, sha); + return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths)); }, }, { diff --git a/modules/gitea/trunk.ts b/modules/gitea/trunk.ts index e2ed8572..40433b90 100644 --- a/modules/gitea/trunk.ts +++ b/modules/gitea/trunk.ts @@ -12,12 +12,17 @@ // the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a // new head makes — and says how long the last check of that repository took. It never merges by itself after. // +// Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged +// ("pass on "), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict +// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (hq's +// mesh/repo-check, pulls.ts maintained). +// // Pure functions and two small files 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, GiteaClient, GiteaPull, HeadCheck } from "./client.js"; -import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, WAITS_FOR_COMPOSED } from "./pulls.js"; +import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js"; /** A commit as a verdict names it: its first twelve characters. */ export function twelve(sha: string | undefined): string { @@ -40,7 +45,9 @@ export function judgedWords(head: string, trunk: string): string { * verdict put out of date, the one it had judged ("judged against trunk …"). Undefined when it names none, as a * verdict of a build seat from before ADR 0323 does. */ export function trunkNamed(description: string | undefined): string | undefined { - const m = /(?:merged into|judged against) trunk ([0-9a-f]{7,64})\b/i.exec(description ?? ""); + const m = /(?:merged into|judged against) trunk ([0-9a-f]{7,64})\b/i.exec(description ?? "") + // The checker's mesh/check line (ADR 0447 §3): " on ". + ?? /\b[0-9a-f]{12} on ([0-9a-f]{12})\b/i.exec(description ?? ""); return m ? m[1].toLowerCase() : undefined; } @@ -70,15 +77,15 @@ export function waitsForComposed(gate: HeadCheck | undefined): boolean { * gate, and the repository's own check when the head has one — a pending repo-check a verdict will not set would * stand for ever (novox/hq issue 305's rule). */ -export function outOfDateStatuses(checks: Record, trunkHead: string): CommitStatus[] { - const gate = checks[CHECK_CONTEXT]; - if (!gate || gate.state === "pending") return []; - const judged = trunkNamed(gate.description); +export function outOfDateStatuses(checks: Record, trunkHead: string, required?: string[]): CommitStatus[] { + // Only the statuses the base still requires (issue 1301): the first of them the head holds judges. + const kept = maintained(required).filter((c) => checks[c]); + const judge = kept.length ? checks[kept[0]] : undefined; + if (!judge || judge.state === "pending") return []; + const judged = trunkNamed(judge.description); if (judged && sameCommit(judged, trunkHead)) return []; const description = outOfDateWords(judged, trunkHead); - 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; + return kept.map((context) => ({ state: "pending" as const, context, description })); } /** Where the merge tool stands with a head's verdict and the trunk's head (ADR 0323 §5). */ @@ -91,8 +98,8 @@ export interface VerdictAtTrunk { judged?: string; } -export function verdictAtTrunk(checks: Record, trunkHead: string): VerdictAtTrunk { - const gate = checks[CHECK_CONTEXT]; +export function verdictAtTrunk(checks: Record, trunkHead: string, context = CHECK_CONTEXT): VerdictAtTrunk { + const gate = checks[context]; const judged = trunkNamed(gate?.description); if (checkOnItsWay(gate)) return { current: false, onItsWay: true, judged }; return { current: !!gate && gate.state !== "pending" && !!judged && sameCommit(judged, trunkHead), onItsWay: false, judged }; @@ -119,6 +126,9 @@ export function lateVerdictComment(c: { commit: string; trunk?: string; verdict: export interface TrunkForge { listOpenPulls(owner: string, repo: string): Promise; branchHead(owner: string, repo: string, branch: string): Promise; + /** The rule the forge applies to a branch: which of the controller's statuses are kept there (issue 1301). Not given, + * or not read: both, as before. */ + appliedProtection?(owner: string, repo: string, branch: string): Promise<{ enable_status_check?: boolean; status_check_contexts?: string[] } | null>; } /** What it writes with: the merge check's one writer (gate.ts), which decides in the head's own turn. */ @@ -147,11 +157,13 @@ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, tru if (before && sameCommit(before, head)) continue; if (before) { let put = 0; + const required = forge.appliedProtection + ? requiredOf(await forge.appliedProtection(owner, name, base).catch(() => undefined)) : undefined; for (const pull of open.filter((p) => p.base === base)) { // The trunk is read again in the head's own turn: a verdict judged against a head newer than the pass's first // read — the trunk moved again meanwhile, and a check answered — is not put out of date. const written = await gates.outOfDate(owner, name, String(pull.head_sha), - async (checks) => outOfDateStatuses(checks, await forge.branchHead(owner, name, base)), pull.html_url || undefined); + async (checks) => outOfDateStatuses(checks, await forge.branchHead(owner, name, base), required), pull.html_url || undefined); if (written.length) put++; } console.log(`[gitea] ${owner}/${name}'s ${base} moved from ${twelve(before)} to ${twelve(head)}: ${put} open pull ` + diff --git a/modules/gitea/webhook.ts b/modules/gitea/webhook.ts index b0034e94..dba5c3e9 100644 --- a/modules/gitea/webhook.ts +++ b/modules/gitea/webhook.ts @@ -127,7 +127,9 @@ function forgeEventOf(said: string, payload: any): ForgeEvent { 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) } }; + // Its words too (issue 1302): mesh/check's line names the head and the trunk it judged, which the delivery gate reads. + return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state), + description: str(payload.description) } }; } const name = family === "other" ? `other.${kind.replace(/[^a-z0-9_-]/gi, "_") || "unknown"}` : ["fork"].includes(family) ? family : `${family}.${action ?? "changed"}`; diff --git a/modules/i3/cmd/i3-tools/manifest_test.go b/modules/i3/cmd/i3-tools/manifest_test.go index 366a6723..d41fb7ad 100644 --- a/modules/i3/cmd/i3-tools/manifest_test.go +++ b/modules/i3/cmd/i3-tools/manifest_test.go @@ -10,7 +10,6 @@ import ( "os" "os/exec" "path/filepath" - "sort" "strings" "testing" ) @@ -204,41 +203,37 @@ func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) { } } -// Every module of the catalogue that contributes window-manager lines (novox/hq ADR 0212) is placed in -// the main file as the controller places them — in module order, each module's under a line naming it -// — and loaded as i3 would load them on a machine with all of them assigned: no two bind one key, and -// every line parses. +// The window-manager lines the catalogue's modules contribute to the main file (novox/hq ADR 0212) are kept in +// testdata/window-manager-lines.json, one entry per contributing module in module order, as the controller places +// them. The test reads that file, never the other modules' manifests: a module may not read a manifest (ADR 0495 +// §1), and a test that reads its siblings' fails wherever they are not beside it (issue 1814). The main file and +// those lines are placed together — each module's under a line naming it — and loaded as i3 would load them on a +// machine with all of them assigned: no two bind one key, and every line parses. func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) { i3, err := exec.LookPath("i3") if err != nil { t.Skip("no i3 here to check with") } dir := t.TempDir() - manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json")) - sort.Strings(manifests) + raw, err := os.ReadFile(filepath.Join("testdata", "window-manager-lines.json")) + if err != nil { + t.Fatal(err) + } + var contributions []struct { + Module string `json:"module"` + Content []string `json:"content"` + } + if err := json.Unmarshal(raw, &contributions); err != nil { + t.Fatal(err) + } var placed strings.Builder var found []string - for _, p := range manifests { - raw, _ := os.ReadFile(p) - var m struct { - Module string `json:"module"` - Contributions []struct { - Seat, Kind, Content string - } `json:"contributions"` - } - json.Unmarshal(raw, &m) - named := false - for _, c := range m.Contributions { - if c.Seat != "node-display-session" || c.Kind != "config" { - continue - } - if !named { - placed.WriteString("# " + m.Module + "\n") - named = true - found = append(found, m.Module) - } - placed.WriteString(c.Content) - if !strings.HasSuffix(c.Content, "\n") { + for _, c := range contributions { + placed.WriteString("# " + c.Module + "\n") + found = append(found, c.Module) + for _, content := range c.Content { + placed.WriteString(content) + if !strings.HasSuffix(content, "\n") { placed.WriteString("\n") } } diff --git a/modules/i3/cmd/i3-tools/testdata/window-manager-lines.json b/modules/i3/cmd/i3-tools/testdata/window-manager-lines.json new file mode 100644 index 00000000..16d0692a --- /dev/null +++ b/modules/i3/cmd/i3-tools/testdata/window-manager-lines.json @@ -0,0 +1,50 @@ +[ + { + "module": "asus-zephyrus-g14", + "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/example-laptop/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-kbd-notify\n# 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/example-laptop/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/example-laptop/bin/zephyrus-touchpad reset\n" + ] + }, + { + "module": "clipmenu", + "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" + ] + }, + { + "module": "dunst", + "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" + ] + }, + { + "module": "feh", + "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" + ] + }, + { + "module": "i3status-rust", + "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" + ] + }, + { + "module": "polychromatic", + "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" + ] + }, + { + "module": "rofi", + "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" + ] + }, + { + "module": "slack", + "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" + ] + } +] \ No newline at end of file diff --git a/modules/kernel/README.md b/modules/kernel/README.md index 6acb6a37..266ef7f2 100644 --- a/modules/kernel/README.md +++ b/modules/kernel/README.md @@ -1,8 +1,9 @@ # 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. +The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). It holds two things: +**transparent huge pages**, set per machine, and **the fallback kernel** (`linux-lts` with its headers) and +its place on the boot menu (novox/hq ADR 0508 §9 and §10). The default kernel's package, and microcode, are +not built yet. ## Why (novox/hq issue 746) @@ -35,16 +36,34 @@ under the new mode. A failed rotation is said in the unit's journal and does not 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. +## The fallback kernel (novox/hq ADR 0508, issue 1841) + +Every machine keeps a second, older kernel to start from, so a kernel upgrade that does not boot is not the +end of the machine. The module declares the packages `linux-lts` and `linux-lts-headers` (so every DKMS module is +built for the fallback kernel too) after its boot-entries service, so the entry is in place before the kernel is. + +| | what | notes | +|---|---|---| +| file | `${dir:state}/boot-entries` | the script. It reads from the machine which loader booted it: the EFI variable `LoaderEntrySelected` present means systemd-boot, a `/boot/grub/grub.cfg` without it means GRUB, neither fails the unit loudly | +| | systemd-boot | writes `/boot/loader/entries/mesh-linux-lts.conf` from `/proc/cmdline` without `initrd=` and `BOOT_IMAGE=`, with the microcode images present in `/boot` and the fallback initramfs as initrd lines; `loader.conf` and the default entry are never touched | +| | GRUB | runs `grub-mkconfig -o /boot/grub/grub.cfg` only when the set of `/boot/vmlinuz-*` differs from the kernels the menu names, with `GRUB_TOP_LEVEL` naming the kernel the machine booted (`BOOT_IMAGE`; else the non-fallback kernel), since GRUB lists kernels in reverse version order and would otherwise make the fallback kernel the default; `/etc/default/grub` still overrides it when it sets one | +| | room | while `/boot/vmlinuz-linux-lts` is not there yet, less than 96 MiB free on `/boot` fails the unit and says so (the fallback kernel writes about 72 MB, issue 1836) | +| | a kernel-copying hook | a hook in `/etc/pacman.d/hooks` that the mesh did not write, triggered by kernel images and copying to one fixed file name in `/boot`, would put the fallback kernel in the default kernel's place: it is moved into `${dir:state}/taken-over/` and named in the unit's journal. Each kernel is then placed under its own package's name by the distribution's hooks | +| file | `/etc/systemd/system/mesh-kernel-boot-entries.service` | oneshot, sandboxed: the file system read-only but for `/boot`, `/etc/pacman.d/hooks` and the module's state; no home, no network | +| file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed | +| packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook | + ## Tools | tool | | answers | |---|---|---| +| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, and the room free on `/boot` in KiB | | `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 +manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages 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 diff --git a/modules/kernel/cmd/kernel-tools/boot.go b/modules/kernel/cmd/kernel-tools/boot.go new file mode 100644 index 00000000..10a895a1 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/boot.go @@ -0,0 +1,89 @@ +package main + +// kernel_boot: which loader booted the machine, its entries, the kernels in /boot, the entry it booted and +// whether the fallback kernel is on the menu (novox/hq ADR 0508 §9, issue 1841). Reads only. + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "syscall" + "unicode/utf16" +) + +const loaderSelected = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" + +// BootState is what kernel_boot answers. +type BootState struct { + Loader string `json:"loader"` + Entries []string `json:"entries"` + Kernels []string `json:"kernels"` + Booted string `json:"booted"` + FallbackPresent bool `json:"fallback_present"` + BootFreeKiB int64 `json:"boot_free_kib"` +} + +var grubKernel = regexp.MustCompile(`(?m)^\s*linux\s+(?:/boot)?/(vmlinuz-\S+)`) +var grubTitle = regexp.MustCompile(`(?m)^\s*menuentry\s+'([^']*)'`) +var bootImage = regexp.MustCompile(`BOOT_IMAGE=(?:/boot)?/?(\S+)`) + +// Boot reads the machine, the loader the way the boot-entries script does: the loader's EFI variable means +// systemd-boot, a grub.cfg without it GRUB. +func (m *Machine) Boot() (*BootState, error) { + b := &BootState{Loader: "none", Entries: []string{}, Kernels: []string{}} + files, _ := filepath.Glob(m.path("boot/vmlinuz-*")) + for _, f := range files { + b.Kernels = append(b.Kernels, filepath.Base(f)) + } + sort.Strings(b.Kernels) + hasKernel := func(name string) bool { + for _, k := range b.Kernels { + if k == name { + return true + } + } + return false + } + var s syscall.Statfs_t + if err := syscall.Statfs(m.path("boot"), &s); err == nil { + b.BootFreeKiB = int64(s.Bavail) * int64(s.Bsize) / 1024 + } + if raw, err := os.ReadFile(m.path(loaderSelected)); err == nil { + b.Loader = "systemd-boot" + if len(raw) > 4 { + var u []uint16 + for i := 4; i+1 < len(raw); i += 2 { + u = append(u, uint16(raw[i])|uint16(raw[i+1])<<8) + } + b.Booted = strings.TrimRight(string(utf16.Decode(u)), "\x00") + } + files, _ := filepath.Glob(m.path("boot/loader/entries/*.conf")) + for _, f := range files { + b.Entries = append(b.Entries, filepath.Base(f)) + } + sort.Strings(b.Entries) + if raw, err := os.ReadFile(m.path("boot/loader/entries/mesh-linux-lts.conf")); err == nil { + b.FallbackPresent = strings.Contains(string(raw), "/vmlinuz-linux-lts") && hasKernel("vmlinuz-linux-lts") + } + return b, nil + } + if raw, err := os.ReadFile(m.path("boot/grub/grub.cfg")); err == nil { + b.Loader = "grub" + for _, t := range grubTitle.FindAllStringSubmatch(string(raw), -1) { + b.Entries = append(b.Entries, t[1]) + } + for _, k := range grubKernel.FindAllStringSubmatch(string(raw), -1) { + if k[1] == "vmlinuz-linux-lts" && hasKernel("vmlinuz-linux-lts") { + b.FallbackPresent = true + } + } + if cmd, err := m.read("proc/cmdline"); err == nil { + if i := bootImage.FindStringSubmatch(cmd); i != nil { + b.Booted = i[1] + } + } + } + return b, nil +} diff --git a/modules/kernel/cmd/kernel-tools/boot_test.go b/modules/kernel/cmd/kernel-tools/boot_test.go new file mode 100644 index 00000000..9c8d964f --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/boot_test.go @@ -0,0 +1,90 @@ +package main + +// kernel_boot over stand-in roots: a systemd-boot machine with and without the fallback entry, a GRUB machine +// with and without the fallback kernel in its menu, and a machine with neither (novox/hq ADR 0508 §9, issue 1841). + +import ( + "os" + "path/filepath" + "testing" +) + +const selectedVar = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" + +func put(t *testing.T, root, p, content string) { + t.Helper() + full := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + write(t, full, content, 0o644) +} + +func bootOf(t *testing.T, root string) *BootState { + t.Helper() + got, err := (&Machine{Root: root}).Boot() + if err != nil { + t.Fatal(err) + } + return got +} + +func TestKernelBootReadsASystemdBootMachineWithItsFallbackEntry(t *testing.T) { + root := t.TempDir() + put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00") + put(t, root, "boot/loader/entries/arch.conf", defaultEntry) + put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\n") + put(t, root, "boot/vmlinuz-linux", "k") + put(t, root, "boot/vmlinuz-linux-lts", "k") + got := bootOf(t, root) + if got.Loader != "systemd-boot" || got.Booted != "arch.conf" || !got.FallbackPresent { + t.Fatalf("%+v", got) + } + if len(got.Entries) != 2 || got.Entries[0] != "arch.conf" || got.Entries[1] != "mesh-linux-lts.conf" { + t.Fatalf("entries: %v", got.Entries) + } + if len(got.Kernels) != 2 || got.Kernels[0] != "vmlinuz-linux" || got.Kernels[1] != "vmlinuz-linux-lts" { + t.Fatalf("kernels: %v", got.Kernels) + } + if got.BootFreeKiB <= 0 { + t.Fatalf("free: %d", got.BootFreeKiB) + } +} + +func TestKernelBootSaysASystemdBootMachineHasNoFallbackYet(t *testing.T) { + root := t.TempDir() + put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00") + put(t, root, "boot/loader/entries/arch.conf", defaultEntry) + put(t, root, "boot/vmlinuz-linux", "k") + // The entry file alone is not the fallback: the kernel it names must be in /boot. + put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "linux /vmlinuz-linux-lts\n") + if got := bootOf(t, root); got.FallbackPresent { + t.Fatalf("a fallback entry without its kernel: %+v", got) + } +} + +func TestKernelBootReadsAGrubMachine(t *testing.T) { + root := t.TempDir() + put(t, root, "boot/grub/grub.cfg", grubMenu) + put(t, root, "boot/vmlinuz-linux", "k") + put(t, root, "proc/cmdline", bootedCmdline) + got := bootOf(t, root) + if got.Loader != "grub" || got.FallbackPresent || got.Booted != "vmlinuz-linux" { + t.Fatalf("%+v", got) + } + if len(got.Entries) != 2 || got.Entries[0] != "Arch Linux" { + t.Fatalf("entries: %v", got.Entries) + } + put(t, root, "boot/vmlinuz-linux-lts", "k") + put(t, root, "boot/grub/grub.cfg", grubMenu+"menuentry 'Arch Linux LTS' {\n\tlinux\t/boot/vmlinuz-linux-lts root=x rw\n}\n") + if got := bootOf(t, root); !got.FallbackPresent { + t.Fatalf("%+v", got) + } +} + +func TestKernelBootSaysWhenThereIsNoLoader(t *testing.T) { + got := bootOf(t, t.TempDir()) + if got.Loader != "none" || got.FallbackPresent || len(got.Entries) != 0 { + t.Fatalf("%+v", got) + } +} diff --git a/modules/kernel/cmd/kernel-tools/bootentries_test.go b/modules/kernel/cmd/kernel-tools/bootentries_test.go new file mode 100644 index 00000000..19044bc3 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/bootentries_test.go @@ -0,0 +1,284 @@ +package main + +// The boot-entries script as the mesh writes it, run with sh over a stand-in root (novox/hq ADR 0508 §9 and +// §10, issue 1841). The fixtures are the shapes read from machines: a systemd-boot entries directory, a GRUB +// menu, a hook that copies every kernel image to the default kernel's name, and the command line a machine +// booted with. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const bootedCmdline = "initrd=\\initramfs-linux.img BOOT_IMAGE=/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3 initrd=\\intel-ucode.img\n" + +const defaultEntry = "title Arch Linux\nlinux /vmlinuz-linux\ninitrd /intel-ucode.img\ninitrd /initramfs-linux.img\noptions root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet\n" + +const loaderConf = "default arch.conf\ntimeout 3\nconsole-mode max\n" + +// grubMenu is the part of a generated grub.cfg that names kernels, for a machine holding one kernel. +const grubMenu = `menuentry 'Arch Linux' --class arch { + set root='hd0,gpt2' + linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet + initrd /boot/initramfs-linux.img +} +menuentry 'Arch Linux, with fallback initramfs' --class arch { + linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet + initrd /boot/initramfs-linux-fallback.img +} +` + +// kernelCopyingHook copies every installed kernel image to the default kernel's name on the boot partition. +const kernelCopyingHook = `[Trigger] +Operation = Install +Operation = Upgrade +Type = Path +Target = usr/lib/modules/*/vmlinuz + +[Action] +Description = Copying the kernel to the default name +When = PostTransaction +Exec = /usr/bin/sh -c '/usr/bin/cp /usr/lib/modules/$(uname -r)/vmlinuz /boot/vmlinuz-linux' +` + +// otherHook is a hook of the distribution's shape that has nothing to do with kernel images. +const otherHook = `[Trigger] +Operation = Upgrade +Type = Package +Target = pacman-mirrorlist + +[Action] +Description = Updating the mirror list +When = PostTransaction +Exec = /usr/bin/cp /etc/pacman.d/mirrorlist.pacnew /etc/pacman.d/mirrorlist +` + +type bootBed struct { + script, root, state, bin, mkconfig string + need string +} + +func newBootBed(t *testing.T) bootBed { + t.Helper() + dir := t.TempDir() + b := bootBed{script: filepath.Join(dir, "boot-entries"), root: filepath.Join(dir, "root"), state: filepath.Join(dir, "state"), + bin: filepath.Join(dir, "bin"), mkconfig: filepath.Join(dir, "mkconfig")} + content := manifest(t).resource(t, "boot-entries").text(t) + 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") + } + for _, d := range []string{b.root + "/boot", b.root + "/proc", b.root + "/etc/pacman.d/hooks", b.state, b.bin} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + write(t, b.script, content, 0o755) + write(t, b.root+"/proc/cmdline", bootedCmdline, 0o644) + // the stand-in grub-mkconfig records the kernel it was told to put first, then its arguments. + write(t, filepath.Join(b.bin, "grub-mkconfig"), "#!/bin/sh\necho \"top=$GRUB_TOP_LEVEL $@\" >>"+b.mkconfig+"\n", 0o755) + return b +} + +func (b bootBed) put(t *testing.T, p, content string) { + t.Helper() + full := filepath.Join(b.root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + write(t, full, content, 0o644) +} + +func (b bootBed) systemdBoot(t *testing.T) { + b.put(t, "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f", "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00") + b.put(t, "boot/loader/loader.conf", loaderConf) + b.put(t, "boot/loader/entries/arch.conf", defaultEntry) + b.put(t, "boot/vmlinuz-linux", "k") +} + +func (b bootBed) run(t *testing.T) (string, error) { + t.Helper() + args := []string{b.script, "run", b.root, b.state} + if b.need != "" { + args = append(args, b.need) + } + cmd := exec.Command("sh", args...) + cmd.Env = append(os.Environ(), "PATH="+b.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func (b bootBed) read(t *testing.T, p string) string { + t.Helper() + raw, _ := os.ReadFile(filepath.Join(b.root, p)) + return string(raw) +} + +func TestASystemdBootMachineGetsTheFallbackEntryFromItsBootedCommandLine(t *testing.T) { + b := newBootBed(t) + b.systemdBoot(t) + b.put(t, "boot/intel-ucode.img", "u") + out, err := b.run(t) + if err != nil || !strings.Contains(out, "wrote") { + t.Fatalf("%v %s", err, out) + } + want := "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\ninitrd /intel-ucode.img\ninitrd /initramfs-linux-lts.img\n" + + "options root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3\n" + if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); got != want { + t.Fatalf("entry:\n%s\nwant:\n%s", got, want) + } + // The default entry and the loader's own file are as they were. + if b.read(t, "boot/loader/entries/arch.conf") != defaultEntry || b.read(t, "boot/loader/loader.conf") != loaderConf { + t.Fatalf("the default entry or loader.conf was touched") + } + if out, err := b.run(t); err != nil || !strings.Contains(out, "as it was") { + t.Fatalf("again: %v %s", err, out) + } +} + +func TestWithoutMicrocodeThereIsNoMicrocodeLine(t *testing.T) { + b := newBootBed(t) + b.systemdBoot(t) + if out, err := b.run(t); err != nil { + t.Fatalf("%v %s", err, out) + } + if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); strings.Contains(got, "ucode") || !strings.Contains(got, "initrd /initramfs-linux-lts.img") { + t.Fatalf("entry: %s", got) + } +} + +func TestAGrubMachineIsGeneratedAgainOnlyWhenItsKernelsChanged(t *testing.T) { + b := newBootBed(t) + b.put(t, "boot/grub/grub.cfg", grubMenu) + b.put(t, "boot/vmlinuz-linux", "k") + if out, err := b.run(t); err != nil || !strings.Contains(out, "left as it is") { + t.Fatalf("same: %v %s", err, out) + } + if _, err := os.Stat(b.mkconfig); err == nil { + t.Fatalf("the menu was generated again with the same kernels") + } + b.put(t, "boot/vmlinuz-linux-lts", "k") + if out, err := b.run(t); err != nil || !strings.Contains(out, "generated again") { + t.Fatalf("changed: %v %s", err, out) + } + raw, _ := os.ReadFile(b.mkconfig) + if string(raw) != "top=/boot/vmlinuz-linux -o "+filepath.Join(b.root, "boot/grub/grub.cfg")+"\n" { + t.Fatalf("grub-mkconfig was asked %q", raw) + } +} + +// ADR 0508 §9: GRUB's menu lists kernels in reverse version order, so linux-lts would come first and become the +// default entry (GRUB_DEFAULT=0); the kernel the machine booted is put first, and the fallback kernel never. +func TestGrubKeepsTheBootedKernelFirstAndNeverTheFallback(t *testing.T) { + for name, c := range map[string]struct{ cmdline, want string }{ + "booted from the default kernel": {"BOOT_IMAGE=/vmlinuz-linux root=UUID=x rw\n", "top=/boot/vmlinuz-linux"}, + "booted from a grub device path": {"BOOT_IMAGE=(hd0,msdos1)/vmlinuz-linux-zen root=UUID=x rw\n", "top=/boot/vmlinuz-linux-zen"}, + "booted from the fallback kernel": {"BOOT_IMAGE=/vmlinuz-linux-lts root=UUID=x rw\n", "top=/boot/vmlinuz-linux"}, + "no image on the command line": {"root=UUID=x rw\n", "top=/boot/vmlinuz-linux"}, + "an image no longer in /boot": {"BOOT_IMAGE=/vmlinuz-linux-gone root=UUID=x rw\n", "top=/boot/vmlinuz-linux"}, + } { + b := newBootBed(t) + b.put(t, "boot/grub/grub.cfg", grubMenu) + b.put(t, "proc/cmdline", c.cmdline) + for _, k := range []string{"vmlinuz-linux", "vmlinuz-linux-lts", "vmlinuz-linux-zen"} { + b.put(t, "boot/"+k, "k") + } + if out, err := b.run(t); err != nil { + t.Fatalf("%s: %v %s", name, err, out) + } + raw, _ := os.ReadFile(b.mkconfig) + if !strings.HasPrefix(string(raw), c.want+" -o ") { + t.Errorf("%s: grub-mkconfig was asked %q, want %s first", name, raw, c.want) + } + } +} + +func TestAMachineWithNoLoaderFailsLoudly(t *testing.T) { + b := newBootBed(t) + out, err := b.run(t) + if err == nil || !strings.Contains(out, "no boot loader found") { + t.Fatalf("%v %s", err, out) + } +} + +func TestABootPartitionWithoutRoomFailsBeforeAnythingIsWritten(t *testing.T) { + b := newBootBed(t) + b.systemdBoot(t) + b.need = "999999999999" + out, err := b.run(t) + if err == nil || !strings.Contains(out, "free room there before the kernel is installed") { + t.Fatalf("%v %s", err, out) + } + if b.read(t, "boot/loader/entries/mesh-linux-lts.conf") != "" { + t.Fatalf("an entry was written") + } + // Once the fallback kernel is in place the room is not asked for again. + b.put(t, "boot/vmlinuz-linux-lts", "k") + if out, err := b.run(t); err != nil { + t.Fatalf("%v %s", err, out) + } +} + +func TestAHookThatCopiesKernelsToOneFixedNameIsMovedIntoTheStateAndNamed(t *testing.T) { + b := newBootBed(t) + b.systemdBoot(t) + b.put(t, "etc/pacman.d/hooks/60-copy-kernel.hook", kernelCopyingHook) + b.put(t, "etc/pacman.d/hooks/70-mirrorlist.hook", otherHook) + b.put(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", "[Trigger]\nTarget = usr/lib/modules/*/vmlinuz\n[Action]\nExec = /bin/cp x /boot/y\n") + out, err := b.run(t) + if err != nil || !strings.Contains(out, "taken over: the pacman hook 60-copy-kernel.hook") { + t.Fatalf("%v %s", err, out) + } + if b.read(t, "etc/pacman.d/hooks/60-copy-kernel.hook") != "" { + t.Fatalf("the copying hook is still in the hook directory") + } + if raw, _ := os.ReadFile(filepath.Join(b.state, "taken-over", "60-copy-kernel.hook")); string(raw) != kernelCopyingHook { + t.Fatalf("moved hook: %q", raw) + } + if b.read(t, "etc/pacman.d/hooks/70-mirrorlist.hook") != otherHook || b.read(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook") == "" { + t.Fatalf("a hook that copies no kernel was moved") + } +} + +func TestTheBootEntriesUnitAndHookAreTheModules(t *testing.T) { + m := manifest(t) + unit := m.resource(t, "boot-unit").text(t) + for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/boot-entries run", "ProtectSystem=strict", + "ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks", "NoNewPrivileges=yes", "WantedBy=multi-user.target"} { + if !strings.Contains(unit, line+"\n") { + t.Errorf("the unit lacks %q", line) + } + } + hook := m.resource(t, "boot-hook") + text := hook.text(t) + if hook["path"] != "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook" || !strings.Contains(text, "Target = usr/lib/modules/*/vmlinuz\n") || + !strings.Contains(text, "When = PostTransaction\n") || !strings.Contains(text, "Exec = ${dir:state}/boot-entries run\n") { + t.Fatalf("the hook: %v\n%s", hook["path"], text) + } + apply := m.resource(t, "boot-apply") + if apply["unit"] != "mesh-kernel-boot-entries.service" || apply["state"] != "running" || apply["boot"] != "enabled" { + t.Fatalf("apply: %v", apply) + } +} + +// The fallback kernel and its headers are declared after the boot-entries service, so the entry is placed +// before the kernel is installed and every DKMS module is built for it (ADR 0508 §9). +func TestTheFallbackKernelAndItsHeadersAreDeclaredAfterTheBootEntriesUnit(t *testing.T) { + m := manifest(t) + at := map[string]int{} + for i, r := range m.Resources { + at[r["id"].(string)] = i + } + for id, pkg := range map[string]string{"linux-lts": "linux-lts", "linux-lts-headers": "linux-lts-headers"} { + r := m.resource(t, id) + if r["type"] != "package" || r["package"] != pkg || r["absent"] == true { + t.Errorf("%s: %v", id, r) + } + if at[id] < at["boot-apply"] || at[id] < at["boot-hook"] { + t.Errorf("%s is declared before the boot-entries unit and hook", id) + } + } +} diff --git a/modules/kernel/cmd/kernel-tools/main.go b/modules/kernel/cmd/kernel-tools/main.go index ec15c916..93cdece8 100644 --- a/modules/kernel/cmd/kernel-tools/main.go +++ b/modules/kernel/cmd/kernel-tools/main.go @@ -46,5 +46,15 @@ func tools(m *Machine) []stdio.Tool { return m.HugePages(n) }, }, + { + Name: "kernel_boot", + Description: "How this machine boots and whether the fallback kernel is on its menu (novox/hq ADR 0508 §9): the loader that booted it " + + "(systemd-boot, read from the EFI variable it sets, grub, or none), the menu's entries, the kernels in /boot, the entry " + + "the machine booted (systemd-boot) or the kernel image it booted (grub), whether the fallback kernel linux-lts is on " + + "the menu with its kernel in /boot, and the room free on /boot in KiB (the fallback kernel needs about 72 MB). " + + "Replaces ls /boot, bootctl list, reading grub.cfg and df /boot. (r)", + Input: schema(map[string]any{}), + Run: func(args map[string]any) (any, error) { return m.Boot() }, + }, } } diff --git a/modules/kernel/cmd/kernel-tools/manifest_test.go b/modules/kernel/cmd/kernel-tools/manifest_test.go index a3594f35..aad91fc6 100644 --- a/modules/kernel/cmd/kernel-tools/manifest_test.go +++ b/modules/kernel/cmd/kernel-tools/manifest_test.go @@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) { for _, r := range m.Resources { ids = append(ids, r["id"].(string)) } - if strings.Join(ids, " ") != "state huge-pages unit apply" { + if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" { t.Fatalf("order: %v", ids) } script := m.resource(t, "huge-pages") diff --git a/modules/kernel/files/90-mesh-kernel-boot-entries.hook b/modules/kernel/files/90-mesh-kernel-boot-entries.hook new file mode 100644 index 00000000..d671caaf --- /dev/null +++ b/modules/kernel/files/90-mesh-kernel-boot-entries.hook @@ -0,0 +1,13 @@ +# Module kernel, novox/hq ADR 0508 §9, issue 1841. After a kernel image is installed, upgraded or removed, the +# fallback kernel's boot entry (or the GRUB menu) is placed again. Written by the mesh; change the module. +[Trigger] +Operation = Install +Operation = Upgrade +Operation = Remove +Type = Path +Target = usr/lib/modules/*/vmlinuz + +[Action] +Description = Placing the fallback kernel on the boot menu +When = PostTransaction +Exec = ${dir:state}/boot-entries run diff --git a/modules/kernel/files/boot-entries.sh b/modules/kernel/files/boot-entries.sh new file mode 100644 index 00000000..ed174906 --- /dev/null +++ b/modules/kernel/files/boot-entries.sh @@ -0,0 +1,133 @@ +#!/bin/sh +# The fallback kernel's place on this machine's boot menu (module kernel, novox/hq ADR 0508 §9 and §10, +# issue 1841). Written by the mesh and run as root by mesh-kernel-boot-entries.service at boot and when the +# module is applied, and by the pacman hook 90-mesh-kernel-boot-entries.hook after a kernel is installed, +# upgraded or removed. Replaced on every push; change the module, never this file. +# +# boot-entries run [ [ []]] +# +# The three are arguments only so the module's tests can run this against a stand-in tree. +# +# It reads from the machine which loader booted it, never from a setting: +# systemd-boot the EFI variable LoaderEntrySelected is present: the entry mesh-linux-lts.conf is written +# from the command line the machine booted with. +# GRUB /boot/grub/grub.cfg exists and that variable does not: the menu is generated again, only +# when the set of kernels in /boot differs from the menu's. +# neither it fails loudly. The default entry is never touched. +# And a pacman hook of someone else's that copies kernel images to one fixed name in /boot is moved into this +# module's state first (ADR 0508 §10): it would put the fallback kernel in the default kernel's place. +set -eu + +root=/ +state='${dir:state}' +[ $# -ge 2 ] && root=$(printf '%s' "$2" | sed 's|/$||') +[ $# -ge 3 ] && state=$3 +boot=$root/boot +hooks=$root/etc/pacman.d/hooks +efivar=$root/sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f +ours=90-mesh-kernel-boot-entries.hook +# The fallback kernel and its initramfs are about 72 MB in /boot (issue 1836): less room fails the unit +# before a package writes half of them. +need_kib=98304 +[ $# -ge 4 ] && need_kib=$4 + +[ $# -ge 1 ] && [ "$1" = run ] || { + echo "usage: boot-entries run [ [ []]]" >&2 + exit 2 +} + +# A hook that is not ours, triggered by kernel images, whose action copies to one fixed name in /boot. +copies_kernels() { + grep -Eqi '^[[:space:]]*Target[[:space:]]*=.*(vmlinuz|usr/lib/modules)' "$1" && + grep -Eq '^[[:space:]]*Exec[[:space:]]*=.*(cp|install|ln|rsync)[[:space:]].*/boot/[A-Za-z0-9._+-]+([^A-Za-z0-9._+/%$*-]|$)' "$1" +} + +if [ -d "$hooks" ]; then + for hook in "$hooks"/*.hook; do + [ -f "$hook" ] || continue + [ "$(basename "$hook")" = "$ours" ] && continue + copies_kernels "$hook" || continue + mkdir -p "$state/taken-over" + mv -f "$hook" "$state/taken-over/$(basename "$hook")" + echo "taken over: the pacman hook $(basename "$hook") copied kernel images to one fixed name in /boot; moved to $state/taken-over/ so each kernel keeps its own package's name" + done +fi + +if [ -e "$efivar" ]; then + loader=systemd-boot +elif [ -f "$boot/grub/grub.cfg" ]; then + loader=grub +else + echo "no boot loader found: the machine was not booted by systemd-boot (no LoaderEntrySelected variable) and there is no $boot/grub/grub.cfg; the fallback kernel has no menu to be placed on" >&2 + exit 1 +fi + +# Room first, only while the fallback kernel is not in /boot yet. +if [ ! -e "$boot/vmlinuz-linux-lts" ]; then + free=$(df -Pk "$boot" | awk 'NR==2 {print $4}') + [ -n "$free" ] || free=0 + if [ "$free" -lt "$need_kib" ]; then + echo "$boot has $free KiB free and the fallback kernel needs $need_kib KiB: free room there before the kernel is installed" >&2 + exit 1 + fi +fi + +case "$loader" in +systemd-boot) + entries=$boot/loader/entries + cmdline=$(sed -e 's/BOOT_IMAGE=[^ ]*//g' -e 's/initrd=[^ ]*//g' "$root/proc/cmdline" | tr -s ' \n' ' ' | sed -e 's/^ //' -e 's/ $//') + [ -n "$cmdline" ] || { + echo "$root/proc/cmdline holds no command line to boot the fallback kernel with" >&2 + exit 1 + } + want=$( + echo "title Arch Linux (linux-lts, fallback)" + echo "linux /vmlinuz-linux-lts" + for ucode in intel-ucode.img amd-ucode.img; do + [ -e "$boot/$ucode" ] && echo "initrd /$ucode" + done + echo "initrd /initramfs-linux-lts.img" + echo "options $cmdline" + ) + mkdir -p "$entries" + if [ "$(cat "$entries/mesh-linux-lts.conf" 2>/dev/null || true)" = "$want" ]; then + echo "systemd-boot: mesh-linux-lts.conf, as it was" + else + printf '%s\n' "$want" >"$entries/mesh-linux-lts.conf" + echo "systemd-boot: wrote $entries/mesh-linux-lts.conf from the command line the machine booted with; the default entry is as it was" + fi + ;; +grub) + cfg=$boot/grub/grub.cfg + menu=$(sed -n 's|^[[:space:]]*linux[[:space:]]*\(/boot\)\{0,1\}/\(vmlinuz-[^[:space:]]*\).*|\2|p' "$cfg" | sort -u) + have=$(cd "$boot" && ls -d vmlinuz-* 2>/dev/null | sort -u || true) + if [ "$menu" = "$have" ]; then + echo "grub: the menu lists the kernels in /boot, left as it is" + else + # GRUB lists kernels in reverse version order, so the fallback kernel would come first and become the + # default entry: the kernel the machine booted is put first (GRUB_TOP_LEVEL; /etc/default/grub still + # overrides it when it sets one), the fallback kernel never. + top=$(tr ' ' '\n' <"$root/proc/cmdline" | sed -n 's|^BOOT_IMAGE=\(.*\)$|\1|p' | head -n 1) + top=$(printf '%s' "$top" | sed 's|.*/||') + case "$top" in + vmlinuz-linux-lts | "") top= ;; + esac + [ -n "$top" ] && [ -e "$boot/$top" ] || top= + if [ -z "$top" ]; then + if [ -e "$boot/vmlinuz-linux" ]; then + top=vmlinuz-linux + else + top=$(printf '%s\n' $have | grep -v '^vmlinuz-linux-lts$' | head -n 1 || true) + fi + fi + if [ -n "$top" ]; then + GRUB_TOP_LEVEL=/boot/$top grub-mkconfig -o "$cfg" + else + grub-mkconfig -o "$cfg" + fi + first=$top + [ -n "$first" ] || first="the menu's own order" + echo "grub: the kernels in /boot changed ($(echo $have | tr '\n' ' ')); the menu is generated again with $first first; the default entry is as it was" + fi + ;; +esac diff --git a/modules/kernel/files/mesh-kernel-boot-entries.service b/modules/kernel/files/mesh-kernel-boot-entries.service new file mode 100644 index 00000000..56cfa319 --- /dev/null +++ b/modules/kernel/files/mesh-kernel-boot-entries.service @@ -0,0 +1,22 @@ +[Unit] +Description=The fallback kernel's place on this machine's boot menu +# Module kernel, novox/hq ADR 0508 §9, issue 1841. Starting this unit reads which loader booted the machine and +# places the fallback kernel's boot entry (systemd-boot) or generates the menu again (GRUB); it fails, and says +# why, when there is no loader or the boot partition has no room. The kernel packages are declared after this +# unit, so the entry is in place before they are installed. +After=local-fs.target +Wants=local-fs.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/boot-entries run +# It writes the boot partition, the pacman hook directory and its own state, and nothing else. +ProtectSystem=strict +ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks +ProtectHome=yes +PrivateNetwork=yes +NoNewPrivileges=yes + +[Install] +WantedBy=multi-user.target diff --git a/modules/kernel/module.json b/modules/kernel/module.json index 67d32156..2a63563e 100644 --- a/modules/kernel/module.json +++ b/modules/kernel/module.json @@ -13,7 +13,8 @@ } }, "tools": [ - "kernel_huge_pages" + "kernel_huge_pages", + "kernel_boot" ], "resources": [ { @@ -53,6 +54,52 @@ "health": { "kind": "unit" } + }, + { + "id": "boot-entries", + "type": "file", + "path": "${dir:state}/boot-entries", + "mode": "0755", + "trusted": true, + "template": "files/boot-entries.sh" + }, + { + "id": "boot-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-kernel-boot-entries.service", + "mode": "0644", + "template": "files/mesh-kernel-boot-entries.service" + }, + { + "id": "boot-hook", + "type": "file", + "path": "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", + "mode": "0644", + "template": "files/90-mesh-kernel-boot-entries.hook" + }, + { + "id": "boot-apply", + "type": "service", + "unit": "mesh-kernel-boot-entries.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "boot-unit", + "boot-entries" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "linux-lts", + "type": "package", + "package": "linux-lts" + }, + { + "id": "linux-lts-headers", + "type": "package", + "package": "linux-lts-headers" } ], "build": { diff --git a/modules/mesh-build-go/README.md b/modules/mesh-build-go/README.md index 49d164c3..bdd3f286 100644 --- a/modules/mesh-build-go/README.md +++ b/modules/mesh-build-go/README.md @@ -65,14 +65,16 @@ integration-tagged test runs the real go on the fixtures. ## The integration level -`build_go_test` with `level: integration` runs `go test -tags integration` against a store and a bus started for the -run alone (novox/hq ADR 0470). The store is a postgres of the installed package: `initdb` and `pg_ctl` into +`build_go_test` with `level: integration` runs `go test -tags integration,isolation` (isolation: the bubblewrap proofs, +which a build node can run) against a store and a bus started for the run alone (novox/hq ADR 0470). The store is a postgres of the installed package: `initdb` and `pg_ctl` into `//run-/pg`, as the module's account, listening on a unix socket there and on no TCP address, with durability off; the tests get `MESH_TEST_POSTGRES` (the socket directory percent-encoded in the URL, because the controller's tests cut the URL at its last slash). The bus is a nats-server from the Go library at the version this module's `go.mod` names, run by this same binary started as `mesh-build-go bus ` (`bus.go`: JetStream on, a loopback port of its own, its address on the first line, stopped when its input closes); the tests get `MESH_TEST_NATS`. -Both run inside the step, are stopped, and the run's directory is removed whatever the tests did. +Both run inside the step, are stopped, and the run's directory is removed whatever the tests did. The store, the bus and +the tests run together in one bubblewrap (ADR 0448, issue 1309): this binary again as `mesh-build-go level `, which +prints the level's result as one line of JSON, so the tests reach the level's own store and bus and nothing of the machine. Setup (compiling the tests, the store, the bus) is judged within 30 s and the tests' running time (the wall time of `go test`) within 60 s, apart; a level over either says `over_bound` and names the bound, with the slowest tests for the running @@ -87,7 +89,9 @@ is said until a source exists. one digest of everything those tests read (ADR 0445 §5): the files, by content and under their path in the tree, of the package and every package its test binary reaches (`go list -test -deps`) that lies in the module's own tree; the version and checksum of every module outside it; the package's own `testdata/` files; and the level, the build tags and, at the -integration level, the postgres major and the nats-server version. The Go version is not in it: the toolchain enters a +integration level, the postgres major and the nats-server version; and, when the fetch placed siblings beside the +workspace (`beside`), each one's repository and commit, which the answer echoes as `beside` (the checker keeps a verdict only +for the siblings it was judged with). The text's form is `DigestVersion` 3. The Go version is not in it: the toolchain enters a fingerprint once, as the version the build nodes agree on (ADR 0477 §3), and the standard library's packages are that version's. A package with no tests at the level has no digest and is named in `without_tests`. A directory that vendors is listed and tested from its vendor directory (`-mod=vendor`). @@ -101,11 +105,16 @@ that vendors), `go build -ldflags "-s -w -X main.builtFor=" -o .mesh-bui module's directory, by the installed go (`GOTOOLCHAIN=local`) from the module cache the fetch filled and no network (`GOPROXY=off`). The binary is named as the build agent names it. The output directory is packed with mesh-sdk's `Pack`, as the build agent packs; two build nodes on the same toolchain give the same digest for the same tree (ADR 0477 §6). The step ends with the artifact, the binary, the digest, the size and -the path of the archive, which stays in the workspace's `artifacts` directory: publishing it is the builder's, in a later -release. An artifact that is neither a Go bundle nor an archive fails the step naming it. +the reference of the archive: once the build ended, this process pushes it to the artifact store (novox/hq ADR 0498 §1 §2, +mesh-sdk's `buildstep.PushBlob`) under `/` by its digest, a candidate's as a trunk build's, and answers +`artifact-store:////blobs/`; a push that fails fails the step `not stored: : `. The packed bytes never stay on the node. The store's address is `${dir:state}/registry`, placed by the +manifest, which requires `artifact-store`. The go build runs shut off in bubblewrap (ADR 0448, `isolate.go`): no +network, none of the account, the caches beneath a throwaway layer unless the step is told `publish` (a trunk build). An artifact that is neither a Go +bundle nor an archive fails the step naming it. The same tool packs an archive artifact of any module's tree, whatever its language (ADR 0478 §1, issue 1589): the directory named by `from`, or a file pinned by `url` and `sha256`, packed with the same `Pack` as the build agent's `pack` (`testdata/captured/README` says how its digest was captured). A pinned file is fetched, checked against its sha256 (a -mismatch fails the step naming both digests) and laid out as the artifact says (`file`, or unpacked from `within`; gzip and -bzip2, not yet xz); it runs no command and needs no Go. +mismatch fails the step naming both digests) and laid out as the artifact says (`file`, or unpacked from `within`; gzip, bzip2 +and xz, read in Go as the build agent reads it, issue 1590); it runs no command and needs no Go. diff --git a/modules/mesh-build-go/cmd/mesh-build-go/announce.go b/modules/mesh-build-go/cmd/mesh-build-go/announce.go new file mode 100644 index 00000000..2543242e --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/announce.go @@ -0,0 +1,20 @@ +package main + +// What this module keeps on the bus, announced from its code (novox/hq ADR 0495 §2 §5; issue 1790): every state it +// keeps, each readable by others, since another module reads each today (the builder, the checker and the indexer read toolchains; the builder and the checker read steps; gitea reads keys). A state of its own that it +// did not name here is refused at the call (stdio.Keeps). + +import ( + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/announce" +) + +// Kept are the states the module keeps. +var Kept = []announce.State{ + {Name: "toolchains", Readable: true}, + {Name: "keys", Readable: true}, + {Name: "steps", Readable: true}, +} + +// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves. +func keepStates() error { return stdio.Keeps(Kept...) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/announce_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/announce_integration_test.go new file mode 100644 index 00000000..d009863d --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/announce_integration_test.go @@ -0,0 +1,63 @@ +//go:build integration + +package main + +import ( + "bufio" + "encoding/json" + "io" + "testing" + + "github.com/nats-io/nats.go" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/announcetest" +) + +// 1790 (ADR 0495 §5): served as the runtime serves it, the module announces on the bus every state it keeps, as its +// code declares them. The runtime's part, publishing what the bundle asks to publish, is played by relay. +func TestTheModuleAnnouncesEveryStateItKeeps(t *testing.T) { + const module = "mesh-build-go" + announcetest.Check(t, module, Kept, func(url string) func() { + t.Setenv("MESH_MODULE", module) + t.Setenv("MESH_NODE", "shanks") + nc, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + if err := keepStates(); err != nil { + t.Fatal(err) + } + inR, inW := io.Pipe() + outR, outW := io.Pipe() + go func() { _ = stdio.ServeOn(module, nil, inR, outW) }() + go relay(t, module, nc, outR, inW) + return func() { _ = inW.Close(); _ = outW.Close(); nc.Close() } + }) +} + +// relay is the runtime's side of the channel: each mesh/publish the bundle asks is published on its module's event +// subject, mesh.mod..event., and answered. +func relay(t *testing.T, module string, nc *nats.Conn, from io.Reader, to io.Writer) { + sc := bufio.NewScanner(from) + sc.Buffer(make([]byte, 1<<20), 1<<24) + enc := json.NewEncoder(to) + for sc.Scan() { + var m struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Key string `json:"key"` + Body json.RawMessage `json:"body"` + } `json:"params"` + } + if json.Unmarshal(sc.Bytes(), &m) != nil || m.Method != "mesh/publish" { + continue + } + if err := nc.Publish("mesh.mod."+module+".event."+m.Params.Key, m.Params.Body); err != nil { + t.Errorf("publishing %s: %v", m.Params.Key, err) + } + _ = nc.Flush() + _ = enc.Encode(map[string]any{"id": m.ID, "result": map[string]any{}}) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/archive.go b/modules/mesh-build-go/cmd/mesh-build-go/archive.go index 4807201e..a25d6477 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/archive.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/archive.go @@ -24,6 +24,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/therootcompany/xz" ) // maxUpstream is the largest pinned file fetched and the largest it may unpack to, as the build agent's and the @@ -42,9 +43,9 @@ func (m *Module) httpClient() *http.Client { // buildArchive packs one archive artifact of the module in dir: a pinned file when it names a url, else the directory // `from` of the module's tree. -func (m *Module) buildArchive(ctx context.Context, workspace, module, dir string, art catalogue.Artifact) (any, error) { +func (m *Module) buildArchive(ctx context.Context, workspace, module, name, dir string, art catalogue.Artifact) (any, error) { if art.URL != "" { - return m.pinnedArchive(ctx, workspace, module, art) + return m.pinnedArchive(ctx, workspace, module, name, art) } from := strings.Trim(path.Clean("/"+filepath.ToSlash(art.From)), "/") if strings.TrimSpace(art.From) == "" { @@ -76,12 +77,12 @@ func (m *Module) buildArchive(ctx context.Context, workspace, module, dir string if !within(realDir, realRoot) { return nil, fmt.Errorf("the archive %s is taken from %q, which lies outside the module: it resolves to %s, and the module is %s", art.Name, art.From, realRoot, realDir) } - return m.packInto(workspace, module, art.Name, root) + return m.packInto(ctx, workspace, module, name, art.Name, root) } // pinnedArchive fetches the file an artifact is pinned to, checks it against the pin, lays it out as the artifact says // (kept whole as `file`, or unpacked from `within`) and packs it. -func (m *Module) pinnedArchive(ctx context.Context, workspace, module string, art catalogue.Artifact) (any, error) { +func (m *Module) pinnedArchive(ctx context.Context, workspace, module, name string, art catalogue.Artifact) (any, error) { file, err := m.pinnedFile(ctx, workspace, art) if err != nil { return nil, fmt.Errorf("%s: %w", art.Name, err) @@ -110,7 +111,7 @@ func (m *Module) pinnedArchive(ctx context.Context, workspace, module string, ar } else if err := unpackUpstream(file, laid, art.Within); err != nil { return nil, fmt.Errorf("%s: %s (%s): %w", art.Name, art.URL, art.Version, err) } - return m.packInto(workspace, module, art.Name, laid) + return m.packInto(ctx, workspace, module, name, art.Name, laid) } // pinnedFile is the path of the pinned file in the workspace: kept from an earlier build when its bytes are still the @@ -190,10 +191,13 @@ func sumOf(file string) (string, error) { return hex.EncodeToString(hash.Sum(nil)), nil } -// unpackUpstream unpacks a tar archive compressed with gzip or bzip2 into a directory: of its entries only those within -// `within` (all, when empty), with that prefix taken off. Only files and directories, and nothing outside: the -// node-engine unpacks nothing else. An xz archive is refused by name; the build agent reads it, this module does not -// yet (it is a later issue, not a quiet difference in the archive). +// xzDictMax is the largest xz dictionary read (the build agent's): xz's own presets use at most 64 MiB (-9); a stream +// asking for more is refused rather than given the memory. +const xzDictMax = 64 << 20 + +// unpackUpstream unpacks a tar archive compressed with gzip, bzip2 or xz into a directory: of its entries only those +// within `within` (all, when empty), with that prefix taken off. Only files and directories, and nothing outside: the +// node-engine unpacks nothing else. func unpackUpstream(file, into, within string) error { f, err := os.Open(file) if err != nil { @@ -214,9 +218,22 @@ func unpackUpstream(file, into, within string) error { case bytes.HasPrefix(magic, []byte("BZh")): stream = bzip2.NewReader(buffered) case bytes.HasPrefix(magic, []byte{0xfd, '7', 'z', 'X', 'Z', 0x00}): - return errors.New("it is an xz archive, which this build module does not read yet; the build agent does") + // As the build agent reads it (mesh-controller internal/builder/upstream_file.go; issue 1590): in Go + // (github.com/therootcompany/xz, a port of xz-embedded), never by a tool the node may not carry. xzDictMax is + // a real maximum: a block whose LZMA2 dictionary is larger is refused before its dictionary is allocated. The + // reader keeps a filter decoder for every block of a stream, so the file's framing is walked first and a file + // of more blocks than xzBlocksMax is refused before anything is decoded (xz_framing.go). What the stream + // unpacks to is bounded by untar (maxUpstream, maxEntries). + if err := xzWithinLimits(file); err != nil { + return err + } + zipped, err := xz.NewReader(buffered, xzDictMax) + if err != nil { + return fmt.Errorf("it is not an xz stream this build module reads (a dictionary of %d MiB at most): %v", xzDictMax>>20, err) + } + stream = zipped default: - return errors.New("it is not a tar archive compressed with gzip or bzip2; a single file is kept whole by naming it in `file`") + return errors.New("it is not a tar archive compressed with gzip, bzip2 or xz; a single file is kept whole by naming it in `file`") } return untar(stream, into, within) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go b/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go index af421032..0b0930fc 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "compress/gzip" "context" "crypto/sha256" @@ -40,16 +41,11 @@ const ( capturedWithGo = "go1.27.1" ) -// sameAsBuildAgent says the archive at res.Path holds the tar stream the build agent's pack wrote, and, under the Go the +// sameAsBuildAgent says the archive the build pushed holds the tar stream the build agent's pack wrote, and, under the Go the // digests were captured with, has its digest. func sameAsBuildAgent(t *testing.T, res BuildResult, stream, digest string) { t.Helper() - f, err := os.Open(res.Path) - if err != nil { - t.Fatal(err) - } - defer f.Close() - z, err := gzip.NewReader(f) + z, err := gzip.NewReader(bytes.NewReader(archiveOf(t, res))) if err != nil { t.Fatal(err) } @@ -157,8 +153,8 @@ func TestAnArchiveOfADirectoryHasTheBuildAgentsDigest(t *testing.T) { if res.Artifact != "files" || res.Size == 0 || res.Binary != "" { t.Errorf("result = %+v", res) } - if info, err := os.Stat(res.Path); err != nil || info.Size() != int64(res.Size) { - t.Errorf("the archive at %q: %v", res.Path, err) + if len(archiveOf(t, res)) != res.Size { + t.Errorf("the archive pushed is not %d bytes", res.Size) } if len(*calls) != 0 { t.Errorf("an archive ran %d commands", len(*calls)) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/beside_digest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/beside_digest_test.go new file mode 100644 index 00000000..9f14bd9c --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/beside_digest_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The lead's decision L5 (the checker keeps a verdict of a core repository): the fetch writes the siblings it placed +// beside the workspace, and the digest folds each one's repository and commit into every package's digest and echoes +// them, so a verdict is kept only for the siblings it was judged with. +func TestTheSiblingsCommitsAreInEveryDigestAndEchoed(t *testing.T) { + t.Parallel() + m, _ := digestFixture(t) + alone := digests(t, m, "unit") + if len(alone.Beside) != 0 { + t.Errorf("a workspace without siblings echoes %v", alone.Beside) + } + write := func(placed []Sibling) { + raw, _ := json.Marshal(placed) + if err := os.WriteFile(filepath.Join(m.Work, "ws1", besideRecord), raw, 0o600); err != nil { + t.Fatal(err) + } + } + other := strings.Repeat("b", 40) + write([]Sibling{{Repository: "novox/mesh-sdk", Commit: commitSHA, Dir: "mesh-sdk"}, {Repository: "novox/mesh-catalog", Commit: commitSHA, Dir: "mesh-catalog"}}) + with := digests(t, m, "unit") + want := []string{"novox/mesh-catalog " + commitSHA, "novox/mesh-sdk " + commitSHA} + if !slices.Equal(with.Beside, want) { + t.Errorf("echoed %v, want %v", with.Beside, want) + } + write([]Sibling{{Repository: "novox/mesh-sdk", Commit: other, Dir: "mesh-sdk"}, {Repository: "novox/mesh-catalog", Commit: commitSHA, Dir: "mesh-catalog"}}) + moved := digests(t, m, "unit") + for p, d := range alone.Packages { + if with.Packages[p] == d { + t.Errorf("%s: the siblings left the digest as it was without them", p) + } + if moved.Packages[p] == with.Packages[p] { + t.Errorf("%s: a sibling's new commit left the digest as it was", p) + } + } +} + +// The fetch writes the siblings it placed, which the digest reads. +func TestTheFetchRecordsTheSiblingsItPlaced(t *testing.T) { + f := &fakeStep{gomods: "go.mod\n"} + m, states := fetchModule(t, f) + args := map[string]any{"repository": "novox/mesh-controller", "commit": commitSHA, "beside": []any{ + map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA}}} + if _, err := fetchTool(t, m)(args); err != nil { + t.Fatal(err) + } + st := states.waitEnd(t) + var res FetchResult + json.Unmarshal(st.Result, &res) + if st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + raw, err := os.ReadFile(filepath.Join(m.Work, res.Workspace, besideRecord)) + var placed []Sibling + if err != nil || json.Unmarshal(raw, &placed) != nil || len(placed) != 1 || placed[0].Repository != "novox/mesh-sdk" || placed[0].Commit != commitSHA { + t.Errorf("the record is %s (%v)", raw, err) + } +} + +// The checker's agreement: the integration level runs and digests under the tags integration and isolation, so the +// bubblewrap proofs tagged isolation run in every check on a build node. +func TestTheIntegrationLevelDigestsUnderBothTags(t *testing.T) { + t.Parallel() + m, _ := digestFixture(t) + var seen []shutOff + m.Run = throughBubblewrap(t, m.Run, &seen) + digests(t, m, LevelIntegration) + for _, s := range seen { + if !strings.Contains(s.command, " -tags integration,isolation ") { + t.Errorf("the integration digest listed %q", s.command) + } + } + if DigestVersion != "3" { + t.Errorf("the digest's form changed in kind and is still version %s", DigestVersion) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/build.go b/modules/mesh-build-go/cmd/mesh-build-go/build.go index 0ed375c3..b1f89d7e 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/build.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/build.go @@ -3,8 +3,8 @@ package main // The build step (novox/hq ADR 0405 §3 §7, ADR 0453 §7; issue 1487): a Go bundle artifact of a module of a fetched // workspace, built with today's recipe — the build agent's, mesh-controller's internal/builder/toolchain.go (the Go // entry) and builder.go (compile, binaryName, pack) — by the installed Go and no network, packed with mesh-sdk's Pack -// so that its digest is the build agent's for the same tree. The archive is left in the workspace; publishing it is -// the builder's, in a later release. +// so that its digest is the build agent's for the same tree, and pushed to the artifact store by this process once the +// build ended (ADR 0498 §1 §2, push.go). import ( "context" @@ -15,6 +15,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // The Go recipe of the build agent (toolchain.go, the entry of language go): static, trimmed of its own paths and of @@ -33,8 +34,9 @@ type BuildResult struct { Binary string `json:"binary"` Digest string `json:"digest"` Size int `json:"size"` - // Path is the archive, in the workspace's directory of the work directory. - Path string `json:"path"` + // Reference is where the archive is in the artifact store (novox/hq ADR 0498 §1): + // artifact-store:////blobs/. + Reference string `json:"reference"` } func (m *Module) buildTool() StepTool { @@ -49,6 +51,7 @@ func (m *Module) buildTool() StepTool { "workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"}, "module": map[string]any{"type": "string", "description": "the module's directory in the workspace, . for the repository's root"}, "artifact": map[string]any{"type": "string", "description": "the artifact's name in the module's manifest"}, + "publish": map[string]any{"type": "boolean", "description": "true for a merged trunk commit alone: the build writes the shared Go caches; without it (a candidate) they are read beneath a throwaway layer"}, }, "required": []string{"workspace", "module", "artifact"}, }, @@ -76,6 +79,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { workspace, _ := args["workspace"].(string) module, _ := args["module"].(string) name, _ := args["artifact"].(string) + publish, _ := args["publish"].(bool) if !workspaceOK.MatchString(workspace) { return nil, fmt.Errorf("%q is not a workspace id", workspace) } @@ -88,7 +92,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, fmt.Errorf("no module in %q of workspace %s: %w", module, workspace, err) } - manifest, err := catalogue.ParseManifest(raw) + manifest, err := catalogue.ParseManifestForInputs(raw) if err != nil { return nil, fmt.Errorf("the manifest in %q: %w", module, err) } @@ -106,7 +110,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { return nil, fmt.Errorf("the module %s has no artifact %q (it has %s)", manifest.Module, name, strings.Join(names, ", ")) } if art.Kind == "archive" { - return m.buildArchive(ctx, workspace, module, dir, *art) + return m.buildArchive(ctx, workspace, module, manifest.Module, dir, *art) } if art.Kind != "bundle" || art.Language != "go" { return nil, fmt.Errorf("the artifact %s of %s is a %s of language %q: this module builds Go bundles and archives", name, manifest.Module, art.Kind, art.Language) @@ -133,13 +137,19 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { linker += " -X " + recipeSystemStamp + "=" + system } cmd := []string{"-C", dir, "build", "-ldflags", linker, "-o", filepath.Join(out, binary), "./" + strings.Trim(art.From, "./")} - if r := m.Run(ctx, m.buildEnv(dir), "go", cmd...); r.Status != 0 || r.Err != "" { + // Shut off (ADR 0448): the shared caches are written by a trunk build alone (publish), a candidate's go build writes + // beneath a throwaway layer. + run, err := m.stepRunner(workspace, publish) + if err != nil { + return nil, err + } + if r := run(ctx, m.buildEnv(dir), "go", cmd...); r.Status != 0 || r.Err != "" { return nil, fmt.Errorf("go build of %s: %s", art.Name, causeLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout)) } if err := m.insideModule(workspace, dir, out); err != nil { return nil, fmt.Errorf("the output directory of %s: %w", art.Name, err) } - res, err := m.packInto(workspace, module, art.Name, out) + res, err := m.packInto(ctx, workspace, module, manifest.Module, art.Name, out) if err != nil { return nil, err } @@ -147,22 +157,20 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { return res, nil } -// packInto packs a directory with mesh-sdk's Pack, as the build agent packs every artifact, and leaves the archive in -// the workspace's artifacts directory. -func (m *Module) packInto(workspace, module, artifact, root string) (BuildResult, error) { +// packInto packs a directory with mesh-sdk's Pack, as the build agent packs every artifact, and pushes the archive to +// the artifact store from this process, after the build step ended (novox/hq ADR 0498 §1 §2): a candidate's as a trunk +// build's, under / by its digest. The step answers the reference; a push that fails fails the step +// `not stored:`. The packed bytes never stay on the node: they are held in this process until the push answered (the +// lead's decision L9). +func (m *Module) packInto(ctx context.Context, workspace, module, name, artifact, root string) (BuildResult, error) { archive, digest, err := buildstep.Pack(root) if err != nil { return BuildResult{}, fmt.Errorf("packing %s: %w", artifact, err) } - artifacts := filepath.Join(m.Work, workspace, "artifacts") - if err := os.MkdirAll(artifacts, 0o700); err != nil { - return BuildResult{}, err + if err := m.push(ctx, name+"/"+artifact, digest, archive); err != nil { + return BuildResult{}, fmt.Errorf("%s%s: %v", delivery.NotStored, artifact, err) } - path := filepath.Join(artifacts, strings.ReplaceAll(module, "/", "_")+"."+artifact+".tar.gz") - if err := os.WriteFile(path, archive, 0o600); err != nil { - return BuildResult{}, err - } - return BuildResult{Artifact: artifact, Digest: digest, Size: len(archive), Path: path}, nil + return BuildResult{Artifact: artifact, Digest: digest, Size: len(archive), Reference: buildstep.BlobReference(name, artifact, digest)}, nil } // buildEnv is the environment of the compile: the build agent's (CGO_ENABLED=0, GOFLAGS) with the installed go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/build_test.go b/modules/mesh-build-go/cmd/mesh-build-go/build_test.go index 0d36081f..704db9d6 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/build_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/build_test.go @@ -41,7 +41,7 @@ func buildModule(t *testing.T, manifest, content string) (*Module, *keptStates, if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(manifest), 0o644); err != nil { t.Fatal(err) } - m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { *calls = append(*calls, cmdRecord{env: env, line: name + " " + strings.Join(args, " ")}) for i, a := range args { if a == "-o" { @@ -51,7 +51,8 @@ func buildModule(t *testing.T, manifest, content string) (*Module, *keptStates, } } return forgekey.Ran{} - } + }, &[]shutOff{}) // the compile runs shut off (1309) + withStore(t, m) // the archive is pushed to a store of the test's own (ADR 0498) return m, states, calls } @@ -143,13 +144,10 @@ func TestTheArchiveHoldsOneExecutableAndTwoBuildsAreOneDigest(t *testing.T) { m, states, _ := buildModule(t, manifestWith(goBundle), "the program") first, _ := runBuild(t, m, states, "tools") second, _ := runBuild(t, m, states, "tools") - if first.Digest != second.Digest || first.Path != second.Path { + if first.Digest != second.Digest || first.Reference != second.Reference { t.Errorf("two builds: %+v and %+v", first, second) } - raw, err := os.ReadFile(first.Path) - if err != nil { - t.Fatal(err) - } + raw := archiveOf(t, first) sum := sha256.Sum256(raw) if first.Digest != "sha256:"+hex.EncodeToString(sum[:]) { t.Errorf("the digest %s is not the archive's", first.Digest) @@ -239,3 +237,21 @@ func TestABuildOutputDirectoryLinkedOutsideTheModuleIsRefused(t *testing.T) { t.Errorf("a file outside the module was touched: %q, %v", body, err) } } + +// Issue 1751 (ADR 0514 §2, the review's K2): a manifest whose config value falls back to a predefined key builds. The +// builder holds no published list of the keys, so it reads the manifest for its inputs (ParseManifestForInputs), not +// as a judge of config values; claude-code's real manifest (capturedClaudeCodeManifest) is the fixture. +func TestAManifestFallingBackToAPredefinedKeyBuilds(t *testing.T) { + raw := []byte(capturedClaudeCodeManifest) + if !strings.Contains(string(raw), `"predefined": "agent-managed-settings"`) { + t.Fatal("the captured manifest no longer falls back to a predefined key") + } + m, states, _ := buildModule(t, string(raw), "binary") + res, st := runBuild(t, m, states, "tools") + if st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + if res.Artifact != "tools" || res.Binary != "claude-code" || !strings.HasPrefix(res.Digest, "sha256:") { + t.Errorf("result = %+v", res) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/captured_manifest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/captured_manifest_test.go new file mode 100644 index 00000000..69ee6aef --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/captured_manifest_test.go @@ -0,0 +1,257 @@ +package main + +// capturedClaudeCodeManifest is modules/claude-code/module.json of this catalogue at 56c68be7, copied verbatim on +// 2026-10-22 for issue 1751 (the R4e review's finding K2): a real manifest whose config value managed_settings falls +// back to the predefined key agent-managed-settings (ADR 0348 §7), which the builder holds no published list for. It +// lives in a test file, not under testdata, because the catalogue's paths check (ADR 0260) reads every other file of a +// module and the manifest names claude-code's own paths. +const capturedClaudeCodeManifest = `{ + "module": "claude-code", + "version": "1", + "moved": true, + "slug": "agent", + "capabilities": [ + "package-manager" + ], + "requires": [ + "mcp-endpoint", + { + "name": "gitea-read", + "optional": true + } + ], + "binds": { + "mcp-endpoint": "${dir:state}/mcp-endpoint.json" + }, + "secrets": { + "gitea-read": "${dir:state}/gitea-read.secret" + }, + "uses": [ + "operator-channel" + ], + "state": [ + "servers", + "holdings", + "config", + "proposals", + "questions", + "memory", + "waits" + ], + "reads": [ + "claude-licence-manager.bindings" + ], + "tools": [ + "claude_code_status", + "claude_code_render", + "claude_code_guard", + "claude_code_pull", + "claude_code_grant", + "claude_code_add_api_key", + "claude_code_registrations", + "claude_code_memory", + "claude_code_mcp_list", + "claude_code_mcp_register", + "claude_code_mcp_unregister", + "claude_code_skill_list", + "claude_code_skill_register", + "claude_code_skill_unregister", + "claude_code_agent_list", + "claude_code_agent_register", + "claude_code_agent_unregister", + "claude_code_command_list", + "claude_code_command_register", + "claude_code_command_unregister", + "claude_code_hook_list", + "claude_code_hook_register", + "claude_code_hook_unregister", + "claude_code_output_style_list", + "claude_code_output_style_register", + "claude_code_output_style_unregister", + "claude_code_instructions_list", + "claude_code_instructions_register", + "claude_code_instructions_unregister", + "claude_code_instructions_propose", + "claude_code_proposals", + "claude_code_ask", + "claude_code_answer", + "claude_code_wait", + "claude_code_settings_get", + "claude_code_settings_set", + "claude_code_settings_clear", + "claude_code_permission_add", + "claude_code_permission_remove", + "claude_code_config_list", + "claude_code_config_show", + "claude_code_config_status", + "claude_code_config_import", + "claude_code_home_show", + "claude_code_home_remove", + "claude_code_home_removed", + "claude_code_home_restore", + "claude_code_judge", + "claude_code_judged" + ], + "data": { + "own": [ + { + "id": "agent-home", + "path": "${dir:agent-home}", + "class": "valuable", + "why": "the operator's agent's own files: its memory, history and projects" + } + ] + }, + "config": { + "role": { + "type": "string", + "description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.", + "trusted": true, + "default": "" + }, + "mcp_servers": { + "type": "object", + "description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.", + "trusted": true, + "default": {} + }, + "managed_settings": { + "type": "object", + "description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.", + "trusted": true, + "predefined": "agent-managed-settings", + "default": {} + }, + "instructions": { + "type": "object", + "description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + }, + "output_styles": { + "type": "object", + "description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + }, + "skills": { + "type": "object", + "description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.", + "trusted": true, + "default": {} + }, + "commands": { + "type": "object", + "description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + }, + "agents": { + "type": "object", + "description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "claude-code" + }, + { + "id": "managed", + "type": "directory", + "path": "/etc/claude-code", + "mode": "0755", + "unknown": "program" + }, + { + "id": "start", + "type": "file", + "path": "/usr/local/bin/claude-agent", + "mode": "0755", + "template": "files/claude-agent" + }, + { + "id": "agent-account-name", + "type": "file", + "path": "/etc/claude-code/agent-account", + "mode": "0644", + "template": "files/agent-account" + }, + { + "id": "agent-account", + "type": "user", + "name": "${machine:agent-account}", + "home": "${machine:agent-home}", + "root": "${machine:agent-root}" + }, + { + "id": "agent-home", + "type": "directory", + "path": "${machine:agent-home}/.claude", + "mode": "0700", + "owner": "${machine:agent-account}", + "unknown": "module" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "${machine:account}", + "place": ".", + "unknown": "program" + }, + { + "id": "facts", + "type": "file", + "path": "${dir:state}/facts.json", + "mode": "0600", + "owner": "${machine:account}", + "template": "files/facts.json" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "${machine:account}", + "takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"], + "merge": "json", + "template": "files/settings.json" + } + ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/claude-code", + "binary": "claude-code", + "loads": [ + "claude-code" + ], + "env": { + "MESH_CLAUDE_CODE_STATE": "${dir:state}", + "MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json", + "MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} +` diff --git a/modules/mesh-build-go/cmd/mesh-build-go/digest.go b/modules/mesh-build-go/cmd/mesh-build-go/digest.go index 54f640a8..55412464 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/digest.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/digest.go @@ -20,7 +20,13 @@ import ( ) // DigestVersion is the form of the digest's text: changed whenever what the digest hashes changes in kind. -const DigestVersion = "2" // 2: no Go version in the text; the toolchain enters a fingerprint as the agreed version (ADR 0477 §3) +// 2: no Go version in the text; the toolchain enters a fingerprint as the agreed version (ADR 0477 §3). 3: the siblings' +// commits (the lead's decision L5) and the integration level's tags integration,isolation (the checker's agreement). +const DigestVersion = "3" + +// integrationTags are the build tags of the integration level, its run and its digest alike: integration, and isolation +// for the bubblewrap proofs (mesh-sdk's and the build modules'), which a build node can run. +var integrationTags = []string{"integration", "isolation"} // goPackage is the part of `go list -json` a digest reads. type goPackage struct { @@ -71,9 +77,12 @@ func (p *goPackage) hasTests() bool { return len(p.TestGoFiles)+len(p.XTestGoFil // DigestResult is what a digest step answers: one digest per package that has tests at the level, and the packages // that have none. type DigestResult struct { - Level string `json:"level"` - Packages map[string]string `json:"packages"` - WithoutTests []string `json:"without_tests,omitempty"` + Level string `json:"level"` + Packages map[string]string `json:"packages"` + // Beside echoes the siblings folded into every digest, " " sorted: the checker keeps a verdict + // only when they are the siblings it gave the fetch. + Beside []string `json:"beside,omitempty"` + WithoutTests []string `json:"without_tests,omitempty"` } func (m *Module) digestTool() StepTool { @@ -113,20 +122,27 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) { if _, err := m.checkPostgres(ctx); err != nil { return nil, err } - tags = []string{"integration"} + tags = integrationTags header = append(header, "postgres "+strconv.Itoa(m.Recipe.PostgresMajor), "nats-server "+m.Recipe.NatsServer) default: return nil, fmt.Errorf("the level %q is neither unit nor integration", level) } header = append(header, "tags "+strings.Join(tags, ",")) + beside, err := m.besideLines(workspace) + if err != nil { + return nil, err + } + for _, b := range beside { + header = append(header, "beside "+b) + } dirs, err := m.goModDirs(ctx, tree) if err != nil { return nil, err } - res := DigestResult{Level: level, Packages: map[string]string{}} + res := DigestResult{Level: level, Packages: map[string]string{}, Beside: beside} for _, d := range dirs { - pkgs, err := m.listPackages(ctx, tree, d, tags) + pkgs, err := m.listPackages(ctx, workspace, tree, d, tags) if err != nil { return nil, err } @@ -139,14 +155,18 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) { } // listPackages is `go list -test -deps -json` of the directory's packages, by import path. -func (m *Module) listPackages(ctx context.Context, tree, dir string, tags []string) (map[string]*goPackage, error) { +func (m *Module) listPackages(ctx context.Context, workspace, tree, dir string, tags []string) (map[string]*goPackage, error) { abs := filepath.Join(tree, dir) args := []string{"-C", abs, "list", "-test", "-deps", "-json"} if len(tags) > 0 { args = append(args, "-tags", strings.Join(tags, ",")) } args = append(args, "./...") - r := m.Run(ctx, m.goEnvFor(abs), "go", args...) + run, err := m.stepRunner(workspace, false) + if err != nil { + return nil, err + } + r := run(ctx, m.goEnvFor(abs), "go", args...) if r.Status != 0 || r.Err != "" { return nil, fmt.Errorf("go list in %s: %s", dir, causeLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout)) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go index 7bccd406..ff1e8e6e 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go @@ -30,7 +30,7 @@ func digestFixture(t *testing.T) (*Module, string) { m := moduleWith(newStates(), "go1.27.1") m.Home, m.Work, m.Cache = t.TempDir(), work, t.TempDir() real := StepRunner(m.Home) - m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { switch name { case "git": return forgekey.Ran{Stdout: "go.mod\n"} @@ -38,7 +38,7 @@ func digestFixture(t *testing.T) (*Module, string) { return forgekey.Ran{Stdout: "postgres (PostgreSQL) " + strconv.Itoa(m.Recipe.PostgresMajor) + ".1\n"} } return real(ctx, env, name, args...) - } + }, &[]shutOff{}) // go list runs shut off (1309); here the real go, unshut, reads the fixture return m, tree } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index c273c230..a262ddb6 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -67,6 +67,8 @@ func (m *Module) fetchTool() StepTool { "commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"}, "ref": map[string]any{"type": "string", "description": "refs/pull//head when only a pull request's head carries the commit"}, "base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"}, + "dir": buildstep.FetchRequestProperties()["dir"], + "paths": buildstep.FetchRequestProperties()["paths"], "beside": map[string]any{"type": "array", "description": "optional sibling repositories the tests read, each placed as a plain directory (no symlink) under the workspace's beside/, which build_go_test gives the tests as MESH_CHECK_BESIDE", "items": map[string]any{"type": "object", "properties": map[string]any{ "repository": map[string]any{"type": "string", "description": "/ on the forge"}, @@ -81,19 +83,16 @@ func (m *Module) fetchTool() StepTool { } func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) { - repository, _ := args["repository"].(string) - commit, _ := args["commit"].(string) - ref, _ := args["ref"].(string) - base, _ := args["base"].(string) - if base != "" && !commitOK.MatchString(base) { - return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base) - } - siblings, err := siblingsArg(args["beside"]) + // What the builder and the checker ask, in mesh-sdk's one form (ADR 0499 §4): with the module's directory and its + // named paths, the workspace is narrowed to them (buildstep.Narrow). + req, err := buildstep.ParseFetchRequest(args) if err != nil { return nil, err } - if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") { - return nil, fmt.Errorf("%q is not a repository, /", repository) + repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base + siblings, err := siblingsArg(args["beside"]) + if err != nil { + return nil, err } m.CleanWorkspaces(m.now()) id := buildstep.NewID() @@ -115,6 +114,15 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) { if res.Beside, err = m.placeSiblings(ctx, root, siblings); err != nil { return nil, err } + if (base == "" || res.Merge != "") && len(req.Paths) > 0 { + if err := buildstep.Narrow(tree, req); err != nil { + return nil, err + } + // Said beside the tree, so the steps that list the tree's go.mod files leave out those narrowed away. + if err := os.WriteFile(filepath.Join(root, narrowedMark), nil, 0o600); err != nil { + return nil, err + } + } dirs, err := m.goModDirs(ctx, tree) if err != nil { return nil, err @@ -177,6 +185,11 @@ func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) { dirs := []string{} for _, l := range strings.Split(r.Stdout, "\n") { if l = strings.TrimSpace(l); l != "" && !inTestdata(l) { + if narrowed(tree) { + if _, err := os.Stat(filepath.Join(tree, filepath.FromSlash(l))); err != nil { + continue // outside a narrowed workspace's paths (buildstep.Narrow): no module of this build + } + } dirs = append(dirs, filepath.ToSlash(filepath.Dir(l))) } } @@ -268,3 +281,12 @@ func firstLine(s string) string { } return "" } + +// narrowedMark is the file beside a workspace's tree saying the fetch narrowed it to a module's named paths. +const narrowedMark = "narrowed" + +// narrowed says whether the fetch narrowed the workspace whose tree this is. +func narrowed(tree string) bool { + _, err := os.Stat(filepath.Join(filepath.Dir(tree), narrowedMark)) + return err == nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go index ac9e1666..3abc2400 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go @@ -143,8 +143,9 @@ func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) { // template in the files/ archive (novox/hq ADR 0349 §2). func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) { m := readForgeManifest(t) - if len(m.Requires) != 2 || m.Requires[0] != "forge-account" || m.Requires[1] != "go-toolchain" { - t.Errorf("requires = %v, want [forge-account go-toolchain]", m.Requires) + // artifact-store: the module pushes a done build's archive itself (ADR 0498 §2). + if len(m.Requires) != 3 || m.Requires[0] != "forge-account" || m.Requires[1] != "go-toolchain" || m.Requires[2] != "artifact-store" { + t.Errorf("requires = %v, want [forge-account go-toolchain artifact-store]", m.Requires) } keeps := false for _, s := range m.State { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/integration.go b/modules/mesh-build-go/cmd/mesh-build-go/integration.go index 1f112ed8..03de39ab 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/integration.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/integration.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" "git.novox.be/novox/mesh-sdk/go/buildstep" @@ -33,11 +34,22 @@ func (m *Module) integrationBounds() Bounds { return b } -func (m *Module) integration(ctx context.Context, workspace, tree string, packages []string) (res *TestResult, err error) { +func (m *Module) integration(ctx context.Context, workspace, tree string, packages []string, pattern string) (res *TestResult, err error) { major, err := m.checkPostgres(ctx) if err != nil { return nil, err // a *buildstep.Refusal: this level only } + if !m.Inside { + // The store, the bus and the tests run together in one shut-off process (level.go), so the tests reach the + // level's own store and bus and nothing else. + return m.integrationShutOff(ctx, workspace, packages, pattern) + } + return m.integrationHere(ctx, workspace, tree, packages, pattern, major) +} + +// integrationHere is the level itself, in the shut-off process: the store and the bus started for the run, the tests +// compiled and run against them, both stopped and the run's directory removed however it ends. +func (m *Module) integrationHere(ctx context.Context, workspace, tree string, packages []string, pattern string, major int) (res *TestResult, err error) { run := filepath.Join(m.Work, workspace, "run-"+buildstep.NewID()) if err := os.MkdirAll(run, 0o700); err != nil { return nil, err @@ -64,12 +76,13 @@ func (m *Module) integration(ctx context.Context, workspace, tree string, packag // Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run // below finds them built. Whether they compile is for the run to say; its failure is not read here. for _, d := range dirs { - m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), env...), "go", "-C", filepath.Join(tree, d), "test", "-tags", "integration", "-run", "^$", "-count=1", "./...") + m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), env...), "go", "-C", filepath.Join(tree, d), "test", "-tags", strings.Join(integrationTags, ","), "-run", "^$", "-count=1", "./...") } setup := m.clock().Sub(began) return m.runLevel(ctx, tree, levelOpts{ - level: LevelIntegration, tags: []string{"integration"}, packages: packages, bounds: m.integrationBounds(), + workspace: workspace, run: pattern, + level: LevelIntegration, tags: integrationTags, packages: packages, bounds: m.integrationBounds(), env: env, setup: setup, wall: true, store: "postgres " + strconv.Itoa(major), bus: "nats-server " + m.Recipe.NatsServer, diff --git a/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go b/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go index 7f51597f..9d2aafa1 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go @@ -107,6 +107,9 @@ func integModule(t *testing.T, f *integFake) (*Module, *keptStates) { m := moduleWith(states, "go1.27.1") m.Home, m.Work, m.Cache = t.TempDir(), t.TempDir(), t.TempDir() m.Run, m.Now, m.StartBus = f.run, f.now, f.startBus + // These tests judge the level as it runs in its shut-off process (level.go, 1309-S5): the store, the bus and the + // tests side by side. That the step starts that one process shut off is isolate_test.go's. + m.Inside = true if err := os.MkdirAll(filepath.Join(m.Work, "ws1", "tree"), 0o755); err != nil { t.Fatal(err) } @@ -177,7 +180,7 @@ func TestTheStoreListensOnASocketUnderTheRunAndNowhereElse(t *testing.T) { test = c } } - if !strings.Contains(test.line, "-tags integration") { + if !strings.Contains(test.line, "-tags integration,isolation") { // the bubblewrap proofs run too (the checker's agreement) t.Errorf("the run does not ask for the tag: %s", test.line) } env := map[string]string{} @@ -277,7 +280,7 @@ func TestAFailedOrPanickedRunStopsTheServersAndRemovesTheRunDirectory(t *testing m, _ = integModule(t, g) func() { defer func() { recover() }() - m.integration(context.Background(), "ws1", filepath.Join(m.Work, "ws1", "tree"), nil) + m.integration(context.Background(), "ws1", filepath.Join(m.Work, "ws1", "tree"), nil, "") }() checkCleaned(g, m) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/isolate.go b/modules/mesh-build-go/cmd/mesh-build-go/isolate.go new file mode 100644 index 00000000..a50bea28 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/isolate.go @@ -0,0 +1,123 @@ +package main + +// The run steps shut off (novox/hq ADR 0448; issue 1309, lead decision L2 of R4e): every command that runs a change's +// code (go test, go build, go list) runs through mesh-sdk's buildstep.Isolated, in bubblewrap: no network, none of the +// account (its home, its ssh key, its bus login, the runtime directory), the workspace read-write, the shared Go caches +// read-only under a throwaway writable layer, or read-write for a trunk build alone (publish). The fetch step (git, go +// mod download) holds the key and the network and runs none of the change's code, so it runs as before. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// BubblewrapLeast is the oldest bubblewrap a run step is shut off with: --overlay-src and --tmp-overlay, which keep the +// shared caches unchanged by a candidate, came in 0.10. +const BubblewrapLeast = "0.10" + +// caches are the shared warm caches of the module: Go's module cache and its build cache. +func (m *Module) caches() []string { + return []string{filepath.Join(m.Cache, "gomod"), filepath.Join(m.Cache, "gobuild")} +} + +// isolation is how a run step of the workspace is shut off: the workspace (its tree, its siblings, its run directories, +// its packed artifacts) read-write, the caches beneath a throwaway layer unless write, and an environment of a PATH and +// a locale alone (the step's HOME is its own, inside its /tmp). +func (m *Module) isolation(workspace string, write bool) buildstep.Isolation { + path := os.Getenv("PATH") + if path == "" { + path = defaultPath + } + return buildstep.Isolation{ + Workspace: filepath.Join(m.Work, workspace), + Caches: m.caches(), + WriteCaches: write, + Env: []string{"PATH=" + path, "LC_ALL=C"}, + } +} + +// stepRunner is the runner of a run step of the workspace: buildstep.Isolated over m.Run, so every command it runs is +// shut off; write is true for a trunk build alone (the shared caches are written by trunk builds only). Inside is the +// integration level's own process, already running shut off (level.go): it runs its commands as they are. +func (m *Module) stepRunner(workspace string, write bool) (forgekey.Runner, error) { + if m.Inside { + return m.Run, nil + } + for _, c := range m.caches() { + // bubblewrap binds only a directory that is there; the fetch makes them, a step on a fresh node may come first. + if err := os.MkdirAll(c, 0o700); err != nil { + return nil, fmt.Errorf("the shared cache %s: %w", c, err) + } + } + return buildstep.Isolated(m.Run, m.isolation(workspace, write)), nil +} + +// ProbeBubblewrap reads the installed bubblewrap's version (`bwrap --version`: "bubblewrap 0.13.0"). +func ProbeBubblewrap(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, ProbeTimeout) + defer cancel() + r := forgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") + if r.Status != 0 || r.Err != "" { + return "", fmt.Errorf("`%s --version` failed: %s", buildstep.Bubblewrap, firstLine(r.Err+"\n"+r.Stderr)) + } + return parseBubblewrapVersion(r.Stdout) +} + +var bubblewrapVersion = regexp.MustCompile(`^bubblewrap (\d+)\.(\d+)(\.\d+)?\s*$`) + +func parseBubblewrapVersion(out string) (string, error) { + f := bubblewrapVersion.FindStringSubmatch(strings.TrimSpace(out)) + if f == nil { + return "", fmt.Errorf("`%s --version` answered %q", buildstep.Bubblewrap, strings.TrimSpace(out)) + } + return f[1] + "." + f[2] + f[3], nil +} + +// bubblewrapProblem says why the installed bubblewrap cannot shut a run step off, or "" when it can: it is missing, or +// older than BubblewrapLeast. +func bubblewrapProblem(version string, err error) string { + if err != nil { + return "bubblewrap cannot be run, and every build and test step runs in it (ADR 0448): " + err.Error() + } + if !atLeast(version, BubblewrapLeast) { + return fmt.Sprintf("bubblewrap %s installed, and a build or test step needs %s or later (ADR 0448)", version, BubblewrapLeast) + } + return "" +} + +// atLeast says whether the dotted version v is least or later. +func atLeast(v, least string) bool { + a, b := strings.Split(v, "."), strings.Split(least, ".") + for i := 0; i < len(b); i++ { + var x, y int + if i < len(a) { + x, _ = strconv.Atoi(a[i]) + } + y, _ = strconv.Atoi(b[i]) + if x != y { + return x > y + } + } + return true +} + +// checkBubblewrap refuses a step when bubblewrap cannot shut it off; nil Bubblewrap probes nothing (a module that only +// guards). +func (m *Module) checkBubblewrap(ctx context.Context) error { + if m.Bubblewrap == nil { + return nil + } + v, err := m.Bubblewrap(ctx) + if why := bubblewrapProblem(v, err); why != "" { + return &buildstep.Refusal{Text: why, Want: BubblewrapLeast + " or later", Have: v} + } + return nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/isolate_test.go b/modules/mesh-build-go/cmd/mesh-build-go/isolate_test.go new file mode 100644 index 00000000..9d6615c5 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/isolate_test.go @@ -0,0 +1,295 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// shutOff is one command a step ran in bubblewrap: the bubblewrap words before "--", what it bound read-write, read-only +// and beneath a throwaway layer, and the command with its environment. +type shutOff struct { + flags []string + binds, roBinds, overlays []string + env []string + command string + chdir string +} + +// bwrapArity is how many words each bubblewrap option the SDK writes takes. +var bwrapArity = map[string]int{"--unshare-all": 0, "--die-with-parent": 0, "--new-session": 0, "--clearenv": 0, + "--proc": 1, "--dev": 1, "--tmpfs": 1, "--dir": 1, "--chdir": 1, "--overlay-src": 1, "--tmp-overlay": 1, + "--ro-bind": 2, "--ro-bind-try": 2, "--bind": 2, "--symlink": 2, "--setenv": 2} + +// throughBubblewrap is the fake runner of a step's tests: a bubblewrap command is read back (recorded in seen) and its +// command handed to run with the environment bubblewrap was told to set, the way it runs in the step; any other command +// (the fetch's git, postgres --version) goes to run as it is. +func throughBubblewrap(t *testing.T, run forgekey.Runner, seen *[]shutOff) forgekey.Runner { + t.Helper() + var mu sync.Mutex + return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + if name != buildstep.Bubblewrap { + return run(ctx, env, name, args...) + } + var s shutOff + i := 0 + for ; i < len(args) && args[i] != "--"; i++ { + n, ok := bwrapArity[args[i]] + if !ok || i+n >= len(args) { + t.Errorf("bubblewrap was given %q, which the test does not know: %v", args[i], args) + return forgekey.Ran{Status: 1, Err: "unknown bubblewrap option"} + } + s.flags = append(s.flags, args[i]) + switch args[i] { + case "--bind": + s.binds = append(s.binds, args[i+1]) + case "--ro-bind", "--ro-bind-try": + s.roBinds = append(s.roBinds, args[i+1]) + case "--overlay-src": + s.overlays = append(s.overlays, args[i+1]) + case "--setenv": + s.env = append(s.env, args[i+1]+"="+args[i+2]) + case "--chdir": + s.chdir = args[i+1] + } + i += n + } + if i+1 >= len(args) { + t.Errorf("bubblewrap was given no command: %v", args) + return forgekey.Ran{Status: 1, Err: "no command"} + } + s.command = args[i+1] + " " + strings.Join(args[i+2:], " ") + mu.Lock() + *seen = append(*seen, s) + mu.Unlock() + return run(ctx, s.env, args[i+1], args[i+2:]...) + } +} + +// checkShutOff fails the test unless s ran with no network and none of the account: every namespace unshared, the +// environment cleared, nothing of the home or the state bound, the caches beneath a throwaway layer unless write. +func checkShutOff(t *testing.T, m *Module, s shutOff, write bool) { + t.Helper() + for _, f := range []string{"--unshare-all", "--clearenv", "--die-with-parent"} { + if !slices.Contains(s.flags, f) { + t.Errorf("%s ran without %s: %v", s.command, f, s.flags) + } + } + for _, b := range append(append([]string{}, s.binds...), s.roBinds...) { + if m.Home != "" && (b == m.Home || strings.HasPrefix(b, m.Home+"/")) { + t.Errorf("%s was given the account's home: %s", s.command, b) + } + if strings.HasPrefix(b, "/run") { + t.Errorf("%s was given the runtime directory: %s", s.command, b) + } + } + for _, e := range s.env { + if strings.HasPrefix(e, "HOME="+m.Home) && m.Home != "" { + t.Errorf("%s was told the account's home: %s", s.command, e) + } + } + caches := m.caches() + for _, c := range caches { + bound, layered := slices.Contains(s.binds, c), slices.Contains(s.overlays, c) + switch { + case write && (!bound || layered): + t.Errorf("a trunk build's %s does not write the shared cache %s (bound %v, layered %v)", s.command, c, bound, layered) + case !write && (bound || !layered): + t.Errorf("%s may write the shared cache %s (bound %v, layered %v): only a trunk build does", s.command, c, bound, layered) + } + } + if want := filepath.Join(m.Work, "ws1"); !slices.Contains(s.binds, want) { + t.Errorf("%s was not given its workspace %s read-write: %v", s.command, want, s.binds) + } +} + +// 1309-S2: every go test of the unit level runs shut off in bubblewrap: no network, none of the account, the caches +// beneath a throwaway layer. +func TestTheUnitLevelRunsEveryGoTestShutOff(t *testing.T) { + m, states, ws, calls := workspaceFor(t, "failing") + var seen []shutOff + m.Run = throughBubblewrap(t, m.Run, &seen) + if _, st := runUnit(t, m, states, ws); st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + ran := 0 + for _, c := range *calls { + if strings.HasPrefix(c.line, "go ") { + ran++ + } + } + if ran == 0 || len(seen) != ran { + t.Fatalf("%d go commands ran, %d of them shut off: %v", ran, len(seen), *calls) + } + for _, s := range seen { + checkShutOff(t, m, s, false) + } +} + +// 1309-S2: a try's tests: build_go_test's run pattern reaches go test as one -run word; a pattern that is a flag is refused. +func TestTheRunPatternReachesGoTestAndAFlagIsRefused(t *testing.T) { + m, states, ws, calls := workspaceFor(t, "failing") + var seen []shutOff + m.Run = throughBubblewrap(t, m.Run, &seen) + run := stepTool(t, m, "build_go_test") + if _, err := run(map[string]any{"workspace": ws, "level": "unit", "run": "TestA|TestB"}); err != nil { + t.Fatal(err) + } + states.waitEnd(t) + found := false + for _, c := range *calls { + found = found || strings.Contains(c.line, " -run TestA|TestB ") + } + if !found { + t.Errorf("no go test was given -run TestA|TestB: %v", *calls) + } + for _, bad := range []string{"-exec=/bin/sh", "a b", "(unclosed"} { + if runPatternOK(bad) { + t.Errorf("the run pattern %q was taken", bad) + } + } +} + +// 1309-S3 and S4: a candidate's go build runs shut off with the caches beneath a throwaway layer; a trunk build +// (publish) writes them. +func TestABuildRunsShutOffAndOnlyATrunkBuildWritesTheCaches(t *testing.T) { + for _, publish := range []bool{false, true} { + m, states, _ := buildModule(t, manifestWith(goBundle), "binary") + var seen []shutOff + m.Run = throughBubblewrap(t, m.Run, &seen) + run := stepTool(t, m, "build_go_build") + args := map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": "tools"} + if publish { + args["publish"] = true + } + if _, err := run(args); err != nil { + t.Fatal(err) + } + if st := states.waitEnd(t); st.State != delivery.StepPassed { + t.Fatalf("publish %v: the step ended %s: %s", publish, st.State, st.Reason) + } + if len(seen) != 1 || !strings.HasPrefix(seen[0].command, "go ") || !strings.Contains(seen[0].command, " build ") { + t.Fatalf("publish %v: shut off %v, want the one go build", publish, seen) + } + checkShutOff(t, m, seen[0], publish) + } +} + +// 1309-S3: the digest's go list runs shut off. +func TestTheDigestListsPackagesShutOff(t *testing.T) { + m, _ := digestFixture(t) + var seen []shutOff + m.Run = throughBubblewrap(t, m.Run, &seen) + digests(t, m, LevelUnit) + if len(seen) == 0 { + t.Fatal("the digest ran no command shut off") + } + for _, s := range seen { + if !strings.HasPrefix(s.command, "go ") || !strings.Contains(s.command, " list ") { + t.Errorf("the digest shut off %q, want go list", s.command) + } + checkShutOff(t, m, s, false) + } +} + +// 1309-S5: the integration level runs its store, its bus and its tests in ONE shut-off process (this program again, +// `level `), so they reach each other and nothing else; its answer is the level's result. Nothing of the level +// runs outside but reading postgres's version. +func TestTheIntegrationLevelRunsInOneShutOffProcess(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "failing") + m.Executable = "/opt/bundle/mesh-build-go" + want := TestResult{Level: LevelIntegration, Passed: true, Tests: 3, Packages: []PackageResult{}, Store: "postgres 18"} + var outside []string + var seen []shutOff + m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + switch { + case name == "postgres" && len(args) == 1 && args[0] == "--version": + return forgekey.Ran{Stdout: "postgres (PostgreSQL) 18.6\n"} + case name == m.Executable && len(args) == 2 && args[0] == levelArg: + var spec levelSpec + if err := json.Unmarshal([]byte(args[1]), &spec); err != nil || spec.Workspace != ws || spec.Work != m.Work { + t.Errorf("the level's spec %s: %v", args[1], err) + } + raw, _ := json.Marshal(levelAnswer{Result: &want}) + return forgekey.Ran{Stdout: string(raw) + "\n"} + } + outside = append(outside, name+" "+strings.Join(args, " ")) + return forgekey.Ran{Status: 1, Err: "not expected here"} + }, &seen) + run := stepTool(t, m, "build_go_test") + if _, err := run(map[string]any{"workspace": ws, "level": LevelIntegration}); err != nil { + t.Fatal(err) + } + st := states.waitEnd(t) + if st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + var got TestResult + if err := json.Unmarshal(st.Result, &got); err != nil || got.Tests != 3 || !got.Passed || got.Store != "postgres 18" { + t.Errorf("the result %s (%v), want the shut-off process's", st.Result, err) + } + if len(outside) > 0 { + t.Errorf("ran outside the shut-off process: %v", outside) + } + if len(seen) != 1 { + t.Fatalf("shut off %d processes, want one: %v", len(seen), seen) + } + checkShutOff(t, m, seen[0], false) + if !slices.Contains(seen[0].roBinds, m.Executable) || slices.Contains(seen[0].roBinds, filepath.Dir(m.Executable)) { + t.Errorf("the program is bound %v; want the program alone, read-only", seen[0].roBinds) + } +} + +// 1309-S1: a step is refused, and the module is unhealthy, while bubblewrap is missing or older than 0.10, naming it. +func TestAnOldOrMissingBubblewrapRefusesTheStepsAndIsUnhealthy(t *testing.T) { + for _, c := range []struct { + version string + err error + ok bool + }{{"0.13.0", nil, true}, {"0.10.0", nil, true}, {"0.9.0", nil, false}, {"", errors.New("bwrap: not found"), false}} { + m := moduleWith(newStates(), "go1.27.1") + m.Bubblewrap = func(context.Context) (string, error) { return c.version, c.err } + err := m.Check(context.Background()) + h := m.Health(context.Background()) + raw, _ := json.Marshal(h) + if c.ok { + if err != nil || strings.Contains(string(raw), "unhealthy") { + t.Errorf("bubblewrap %q: refused %v, health %s", c.version, err, raw) + } + continue + } + var ref *buildstep.Refusal + if !errors.As(err, &ref) || !strings.Contains(err.Error(), "bubblewrap") { + t.Errorf("bubblewrap %q %v: Check answered %v, want a refusal naming bubblewrap", c.version, c.err, err) + } + if !strings.Contains(string(raw), "unhealthy") || !strings.Contains(string(raw), "bubblewrap") { + t.Errorf("bubblewrap %q %v: health %s, want unhealthy naming bubblewrap", c.version, c.err, raw) + } + } + for out, want := range map[string]string{"bubblewrap 0.13.0\n": "0.13.0", "bubblewrap 0.10": "0.10"} { + if got, err := parseBubblewrapVersion(out); err != nil || got != want { + t.Errorf("%q read as %q, %v", out, got, err) + } + } +} + +// stepTool is the Run of the named tool. +func stepTool(t *testing.T, m *Module, name string) func(map[string]any) (any, error) { + t.Helper() + for _, tool := range m.Tools(m.StepTools()) { + if tool.Name == name { + return tool.Run + } + } + t.Fatalf("no tool %s", name) + return nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/isolation_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/isolation_integration_test.go new file mode 100644 index 00000000..457d6b52 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/isolation_integration_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" +) + +// 1309-S6 (ADR 0448's check): a test of the change, run by the unit level, reads no key and no login of the account, +// resolves no name, reaches no socket of the machine (a TCP port on its loopback, a unix socket) and leaves the shared +// caches unchanged. The probe is testdata/repos/probe. +func TestARunStepReachesNoKeyLoginNameOrSocketAndLeavesTheCachesUnchanged(t *testing.T) { + needTools(t, "the shut-off unit level", "go", "git", "bwrap") + tree := repository(t, "probe") + work, err := os.MkdirTemp("", "w") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(work) }) + if err := os.MkdirAll(filepath.Join(work, "ws1"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Rename(tree, filepath.Join(work, "ws1", "tree")); err != nil { + t.Fatal(err) + } + home := t.TempDir() + key, login := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, "broker") + for _, f := range []string{key, login} { + if err := os.MkdirAll(filepath.Dir(f), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + } + tcp, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer tcp.Close() + sockDir := t.TempDir() + unix, err := net.Listen("unix", filepath.Join(sockDir, "s")) + if err != nil { + t.Fatal(err) + } + defer unix.Close() + m := &Module{Recipe: Current(), Home: home, Work: work, Cache: t.TempDir()} + m.Run = StepRunner(home) + for _, c := range m.caches() { + if err := os.MkdirAll(c, 0o700); err != nil { + t.Fatal(err) + } + } + res, err := m.runLevel(context.Background(), filepath.Join(work, "ws1", "tree"), levelOpts{workspace: "ws1", level: LevelUnit, + bounds: Bounds{Run: UnitBound * 100}, env: []string{"PROBE_KEY=" + key, "PROBE_LOGIN=" + login, + "PROBE_TCP=" + tcp.Addr().String(), "PROBE_SOCKET=" + filepath.Join(sockDir, "s"), "PROBE_CACHE=" + m.caches()[1]}}) + if err != nil { + t.Fatal(err) + } + if !res.Passed { + t.Fatalf("the probe did not run: %+v", res) + } + raw, err := os.ReadFile(filepath.Join(work, "ws1", "tree", "seen.json")) + if err != nil { + t.Fatalf("the probe wrote nothing: %v", err) + } + var seen map[string]bool + if err := json.Unmarshal(raw, &seen); err != nil { + t.Fatal(err) + } + for what, reached := range seen { + if reached && what != "wroteCache" { + t.Errorf("the run step reached %s: %s", what, raw) + } + } + if _, err := os.Stat(filepath.Join(m.caches()[1], "written-by-a-candidate")); err == nil { + t.Errorf("a candidate's step changed the shared cache") + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/level.go b/modules/mesh-build-go/cmd/mesh-build-go/level.go new file mode 100644 index 00000000..30f4299a --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/level.go @@ -0,0 +1,121 @@ +package main + +// The integration level shut off (novox/hq ADR 0448, ADR 0470; issue 1309 S5): its store, its bus and its tests run in +// one bubblewrap of their own, so the tests reach the level's postgres (a socket in the run's directory) and its bus (a +// loopback port of the step's own network) and nothing of the machine. The process in it is this binary started again +// as `mesh-build-go level `: it runs the level as integrationHere does and prints its answer as one line of JSON. +// The tools bundle never runs a level in its own process, and a level never serves tools. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" +) + +// levelArg is the first argument that makes this binary an integration level's process. +const levelArg = "level" + +// levelSpec is what the shut-off process is asked: the workspace, the packages and the -run pattern, the level's bounds. +type levelSpec struct { + Work string `json:"work"` + Cache string `json:"cache"` + Workspace string `json:"workspace"` + Packages []string `json:"packages,omitempty"` + Run string `json:"run,omitempty"` + Setup time.Duration `json:"setup"` + Bound time.Duration `json:"bound"` +} + +// levelAnswer is the one line the process prints: the level's result, or why it could not run. +type levelAnswer struct { + Result *TestResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// executable is this program's path, bound read-only into the step: Executable when a test set it. +func (m *Module) executable() (string, error) { + if m.Executable != "" { + return m.Executable, nil + } + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.EvalSymlinks(exe) +} + +// integrationShutOff runs the integration level in one shut-off process and reads its answer. +func (m *Module) integrationShutOff(ctx context.Context, workspace string, packages []string, pattern string) (*TestResult, error) { + exe, err := m.executable() + if err != nil { + return nil, fmt.Errorf("the integration level's program: %w", err) + } + b := m.integrationBounds() + spec, err := json.Marshal(levelSpec{Work: m.Work, Cache: m.Cache, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run}) + if err != nil { + return nil, err + } + for _, c := range m.caches() { + if err := os.MkdirAll(c, 0o700); err != nil { + return nil, fmt.Errorf("the shared cache %s: %w", c, err) + } + } + iso := m.isolation(workspace, false) + iso.ReadOnly = append(iso.ReadOnly, exe) // the program alone, not the directory it lies in + r := buildstep.Isolated(m.Run, iso)(ctx, nil, exe, levelArg, string(spec)) + var answer levelAnswer + line := lastLine(r.Stdout) + if err := json.Unmarshal([]byte(line), &answer); err != nil || (answer.Result == nil && answer.Error == "") { + return nil, fmt.Errorf("the integration level's shut-off process answered nothing it says (status %d %s): %s", r.Status, r.Err, firstLine(r.Stderr+"\n"+r.Stdout)) + } + if answer.Error != "" { + return nil, fmt.Errorf("%s", answer.Error) + } + return answer.Result, nil +} + +func lastLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + return strings.TrimSpace(lines[len(lines)-1]) +} + +// runLevelProcess is the shut-off process: args is the spec. It prints its answer and returns the exit status, 0 when it +// printed one. +func runLevelProcess(args []string) int { + say := func(a levelAnswer) int { + raw, _ := json.Marshal(a) + fmt.Println(string(raw)) + return 0 + } + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: mesh-build-go level ") + return 2 + } + var spec levelSpec + if err := json.Unmarshal([]byte(args[0]), &spec); err != nil { + return say(levelAnswer{Error: "the level's spec: " + err.Error()}) + } + if !workspaceOK.MatchString(spec.Workspace) { + return say(levelAnswer{Error: fmt.Sprintf("%q is not a workspace id", spec.Workspace)}) + } + m := &Module{Inside: true, Recipe: Current(), Work: spec.Work, Cache: spec.Cache, Home: os.Getenv("HOME"), + IntegrationBounds: Bounds{Setup: spec.Setup, Run: spec.Bound}, Say: func(string, ...any) {}} + m.Run = StepRunner(m.Home) + ctx := context.Background() + major, err := m.checkPostgres(ctx) + if err != nil { + return say(levelAnswer{Error: err.Error()}) + } + tree := filepath.Join(spec.Work, spec.Workspace, "tree") + res, err := m.integrationHere(ctx, spec.Workspace, tree, spec.Packages, spec.Run, major) + if err != nil { + return say(levelAnswer{Error: err.Error()}) + } + return say(levelAnswer{Result: res}) +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/main.go b/modules/mesh-build-go/cmd/mesh-build-go/main.go index 3e2c3a5e..de95f35a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/main.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/main.go @@ -29,6 +29,9 @@ func main() { if len(os.Args) > 1 && os.Args[1] == busArg { os.Exit(runBus(os.Args[2:])) } + if len(os.Args) > 1 && os.Args[1] == levelArg { + os.Exit(runLevelProcess(os.Args[2:])) + } ctx := context.Background() m := &Module{ Node: strings.TrimSpace(os.Getenv("MESH_NODE")), @@ -47,12 +50,19 @@ func main() { m.Home = u.HomeDir } m.Run = StepRunner(m.Home) + m.RegistryFile = m.Home + "/registry" m.Probe = ProbeGo(m.Home, m.Cache) + m.Bubblewrap = ProbeBubblewrap // Outside a launch by the runtime — a build, a check — there is no node and no state to write. if m.Node != "" { go m.Start(ctx) } go startKeeper(ctx) + // Every state it keeps, said on the bus before it serves (ADR 0495 §2; issue 1790). + if err := keepStates(); err != nil { + logf("announcing the states it keeps: %v", err) + os.Exit(1) + } if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil { logf("%v", err) os.Exit(1) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go index 99355848..2791451e 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go @@ -64,7 +64,7 @@ func TestManifestIsValidAndDeclaresTheAccountTheCapabilityAndThePackages(t *test } } slices.Sort(packages) - if want := []string{"git", "openssh", "postgresql"}; !slices.Equal(packages, want) { + if want := []string{"bubblewrap", "git", "openssh", "postgresql"}; !slices.Equal(packages, want) { t.Errorf("packages = %v, want %v", packages, want) } } @@ -144,7 +144,34 @@ func TestGiteaReadsTheBuildModulesKeysAndTheCatalogueAcceptsThemTogether(t *test t.Errorf("gitea reads %v, lacks %s", gitea.Reads, want) } } - if problems := catalogue.StateReadsNothingDeclares(all); len(problems) > 0 { - t.Errorf("a read names a state its owner does not keep: %v", problems) +} + +// 1309-S1: every run step runs in bubblewrap, so the module declares its package (the lead's decision: each build module +// that runs a step through it declares it), beside the user-namespaces capability bubblewrap needs. +func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) { + m := readManifest(t) + if r := resource(t, m, "bubblewrap"); r["type"] != "package" || r["package"] != "bubblewrap" { + t.Errorf("the bubblewrap resource is %v, want the package bubblewrap", r) + } + if !slices.Contains(m.Capabilities, "user-namespaces") { + t.Errorf("capabilities %v lack user-namespaces", m.Capabilities) + } +} + +// 1587-S1 (ADR 0498 §2): the module requires the artifact store and is told its address in a file of its home, which no +// run step is given (a run step has no network either, ADR 0448). +func TestTheModuleRequiresTheArtifactStoreAndIsToldItsAddress(t *testing.T) { + m := readManifest(t) + if !slices.Contains(m.Requires, "artifact-store") { + t.Errorf("requires %v lack artifact-store", m.Requires) + } + r := resource(t, m, "registry") + // The validator reads the template (files/registry) into the resource's content. + if content, _ := r["content"].(string); r["type"] != "file" || r["path"] != "${dir:state}/registry" || !strings.Contains(content, "${seat:mesh-artifact-store:reach}") { + t.Errorf("the registry resource is %v", r) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "registry")) + if err != nil || strings.TrimSpace(string(raw)) != "${seat:mesh-artifact-store:reach}" { + t.Errorf("files/registry is %q (%v), want the store seat's reach", raw, err) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index d0c4d603..ee3cc99c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -50,6 +50,18 @@ type Module struct { IntegrationBounds Bounds // Now is the clock the levels' times are read from; time.Now when nil. Now func() time.Time + // Bubblewrap reads the installed bubblewrap's version (ProbeBubblewrap): a step is refused, and the module is + // unhealthy, while it is missing or older than BubblewrapLeast. Nil probes nothing. + Bubblewrap func(ctx context.Context) (string, error) + // Inside is the integration level's own process, started shut off in bubblewrap by a test step (level.go): its + // commands run as they are, never shut off a second time. + Inside bool + // RegistryFile holds the artifact store's address (host:port), placed by the manifest; Push puts a blob there + // (buildstep.PushBlob when nil; a test's fake store). + RegistryFile string + Push func(ctx context.Context, registry, repository, digest string, body []byte) error + // Executable is this program's path, which the integration level starts again shut off; os.Executable when empty. + Executable string // StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own // program, StartBus in bus.go, when nil). StartBus func(ctx context.Context, dir string) (url string, stop func(), err error) @@ -69,7 +81,10 @@ func (m *Module) guard(installed string, probeErr error) buildstep.Guard { // CheckRound. func (m *Module) Check(ctx context.Context) error { installed, err := m.Probe(ctx) - return m.guard(installed, err).Check(ctx) + if err := m.guard(installed, err).Check(ctx); err != nil { + return err + } + return m.checkBubblewrap(ctx) } // Start checks once, as the module starts, and then every CheckRound until ctx ends. @@ -95,6 +110,9 @@ func (m *Module) Health(ctx context.Context) health.Answer { if err := m.guard(installed, perr).Check(ctx); err != nil { return health.Unhealthy(err.Error()) } + if err := m.checkBubblewrap(ctx); err != nil { + return health.Unhealthy(err.Error()) + } return health.Healthy(fmt.Sprintf("%s installed, the version the module builds with (recipe %s)", installed, m.Recipe.Version())) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go new file mode 100644 index 00000000..e7478478 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go @@ -0,0 +1,52 @@ +//go:build integration + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// ADR 0499 §4 (issue 1758): a fetch asked with the module's directory and its named paths leaves the workspace holding +// only those, the manifest and the lock files; the rest of the commit is gone before any run step; a request naming +// paths without a directory is refused before anything is fetched. go.mod is kept only when named among the paths +// (mesh-sdk's narrowKept is the manifest and the lock files, review finding S1), as a Go build that names its paths does. +func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) { + f := newForge(t) + _, head := f.repo("novox/narrow", map[string]string{ + "README.md": "the repository\n", + "lib/go.mod": "module fixture/lib\n\ngo 1.25.0\n", + "lib/module.json": `{"module":"lib","version":"1"}`, + "lib/cmd/x/x.go": "package main\n\nfunc main() {}\n", + "lib/docs/a.md": "not named\n", + "other/go.mod": "module fixture/other\n\ngo 1.25.0\n", + "other/o.go": "package other\n", + }) + m, states := f.module(t) + res, st := fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "dir": "lib", "paths": []any{"cmd", "go.mod"}}) + if st.State != delivery.StepPassed { + t.Fatalf("the fetch ended %s: %s", st.State, st.Reason) + } + tree := filepath.Join(m.Work, res.Workspace, "tree") + for _, kept := range []string{"lib/go.mod", "lib/module.json", "lib/cmd/x/x.go"} { + if _, err := os.Stat(filepath.Join(tree, kept)); err != nil { + t.Errorf("%s was narrowed away: %v", kept, err) + } + } + for _, gone := range []string{"README.md", "lib/docs/a.md", "other/o.go"} { + if _, err := os.Stat(filepath.Join(tree, gone)); err == nil { + t.Errorf("%s is still in the workspace", gone) + } + } + if strings.Join(res.Modules, ",") != "lib" { + t.Errorf("the fetch answered the modules %v, want lib alone", res.Modules) + } + _, st = fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "paths": []any{"cmd"}}) + if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "no module directory") { + t.Errorf("paths without a directory: %s %q", st.State, st.Reason) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/pins_integration_test.go index d32cfe2b..bfff720c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins_integration_test.go @@ -41,6 +41,7 @@ func TestRealFetchAndBuildOfAMemberPinningAPullHead(t *testing.T) { } _, memberHead := f.repo("novox/member", member) m, states := f.module(t) + withStore(t, m) // the build pushes its archive (ADR 0498) res, st := fetched(t, m, states, map[string]any{"repository": "novox/member", "commit": memberHead}) if st.State != delivery.StepPassed { @@ -49,6 +50,9 @@ func TestRealFetchAndBuildOfAMemberPinningAPullHead(t *testing.T) { if _, err := os.Stat(filepath.Join(m.Work, res.Workspace, "pinned", "dep@"+head, "beside", "dep", "go", "dep.go")); err != nil { t.Errorf("the pull request's repository was not placed beside the workspace: %v", err) } + // The build runs shut off in bubblewrap (1309), which a build node has and build-agent's check container has not: + // there the build half is not run, said loudly; the fetch half above, the pin's own part (issue 1148), always is. + needTools(t, "the shut-off build of a member pinning a pull head", "bwrap") built, err := m.build(context.Background(), map[string]any{"workspace": res.Workspace, "module": ".", "artifact": "tools"}) if err != nil { t.Fatal(fmt.Errorf("the build: %w", err)) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/preview.go b/modules/mesh-build-go/cmd/mesh-build-go/preview.go index 32fbd3af..87ae1066 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/preview.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/preview.go @@ -6,6 +6,7 @@ package main import ( "context" + "encoding/json" "fmt" "io/fs" "os" @@ -104,9 +105,47 @@ func (m *Module) placeSiblings(ctx context.Context, root string, siblings []sibl placed = append(placed, Sibling{Repository: s.repository, Commit: s.commit, Dir: name}) } sort.Slice(placed, func(i, j int) bool { return placed[i].Dir < placed[j].Dir }) + if len(placed) > 0 { + // What the digest folds in (the lead's decision L5): the siblings this workspace's tests read, by commit. + raw, err := json.Marshal(placed) + if err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(root, besideRecord), raw, 0o600); err != nil { + return nil, err + } + } return placed, nil } +// besideRecord is the file in a workspace's root naming the siblings the fetch placed beside it. +const besideRecord = "beside.json" + +// besideLines are the siblings a workspace's fetch placed, as the digest folds them in and echoes them: " +// ", sorted; none when it placed none. +func (m *Module) besideLines(workspace string) ([]string, error) { + raw, err := os.ReadFile(filepath.Join(m.Work, workspace, besideRecord)) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var placed []Sibling + if err := json.Unmarshal(raw, &placed); err != nil { + return nil, fmt.Errorf("the workspace's record of its siblings: %w", err) + } + var out []string + for _, s := range placed { + if !repositoryOK.MatchString(s.Repository) || !commitOK.MatchString(s.Commit) { + return nil, fmt.Errorf("the workspace's record of its siblings names %q at %q", s.Repository, s.Commit) + } + out = append(out, s.Repository+" "+s.Commit) + } + sort.Strings(out) + return out, nil +} + func firstSymlink(dir string) string { var found string filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/preview_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/preview_integration_test.go index a800d22d..8cbd6112 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/preview_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/preview_integration_test.go @@ -171,6 +171,7 @@ func TestRealFetchMergesConflictsAndPlainlyFetches(t *testing.T) { // sibling and passes with it; the sibling is a plain directory with no symlink in it; and a sibling that holds a // symlink fails the fetch naming it. func TestRealTestsReadTheSiblingBesideTheTree(t *testing.T) { + needTools(t, "the shut-off unit level", "go", "git", "bwrap") // the tests run in bubblewrap (1309) f := newForge(t) files := map[string]string{} for _, n := range []string{"go.mod", "beside.go", "beside_test.go"} { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/preview_test.go b/modules/mesh-build-go/cmd/mesh-build-go/preview_test.go index 2fcee7ea..55877c84 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/preview_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/preview_test.go @@ -173,7 +173,7 @@ func TestSiblingsArePlacedBesideAndTheUnitLevelIsGivenTheirDirectory(t *testing. } calls := &[]cmdRecord{} - m.Run = capturedRunner(t, "failing", calls) + m.Run = throughBubblewrap(t, capturedRunner(t, "failing", calls), &[]shutOff{}) if err := os.MkdirAll(filepath.Join(m.Work, res.Workspace, "tree"), 0o755); err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/push.go b/modules/mesh-build-go/cmd/mesh-build-go/push.go new file mode 100644 index 00000000..95291c52 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/push.go @@ -0,0 +1,42 @@ +package main + +// The push of a done build's archive (novox/hq ADR 0498 §1 §2; issue 1587): from this module's own process, after the +// build step ended, with mesh-sdk's one client (buildstep.PushBlob), to the artifact store whose address the manifest +// binds into RegistryFile. No build step reaches the store: a run step has no network (ADR 0448). + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" +) + +// registry is the artifact store's address, host:port, as the manifest bound it into RegistryFile. +func (m *Module) registry() (string, error) { + raw, err := os.ReadFile(m.RegistryFile) + if err != nil { + return "", fmt.Errorf("the artifact store's address cannot be read: %w", err) + } + addr := strings.TrimSpace(string(raw)) + if addr == "" || strings.Contains(addr, "${") || strings.HasPrefix(addr, ":") || strings.HasSuffix(addr, ":") { + return "", fmt.Errorf("the artifact store's address in %s is %q, not host:port", m.RegistryFile, addr) + } + return addr, nil +} + +// push puts body into the artifact store as a blob of repository (/) by its digest: Push when a test +// set it, else buildstep.PushBlob. +func (m *Module) push(ctx context.Context, repository, digest string, body []byte) error { + addr, err := m.registry() + if err != nil { + return err + } + if m.Push != nil { + return m.Push(ctx, addr, repository, digest, body) + } + return buildstep.PushBlob(ctx, &http.Client{Timeout: 10 * time.Minute}, addr, repository, digest, body) +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/push_test.go b/modules/mesh-build-go/cmd/mesh-build-go/push_test.go new file mode 100644 index 00000000..b830db1b --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/push_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// pushed are the blobs the fake artifact store took, by digest, for the tests that read an archive back. +var pushed sync.Map + +// fakeStore makes m push to a store of the test's own: its address in RegistryFile, each blob kept by repository and +// digest; fail, when set, is the store's refusal. +type fakeStore struct { + mu sync.Mutex + blobs map[string][]byte // "@" + fail error + addr string +} + +func withStore(t *testing.T, m *Module) *fakeStore { + t.Helper() + s := &fakeStore{blobs: map[string][]byte{}} + m.RegistryFile = filepath.Join(t.TempDir(), "registry") + if err := os.WriteFile(m.RegistryFile, []byte("store.test:5000\n"), 0o644); err != nil { + t.Fatal(err) + } + m.Push = func(ctx context.Context, registry, repository, digest string, body []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + s.addr = registry + if s.fail != nil { + return s.fail + } + sum := sha256.Sum256(body) + if "sha256:"+hex.EncodeToString(sum[:]) != digest { + t.Errorf("pushed %s with bytes of another digest", digest) + } + s.blobs[repository+"@"+digest] = append([]byte(nil), body...) + pushed.Store(digest, s.blobs[repository+"@"+digest]) + return nil + } + return s +} + +// archiveOf is the archive a build pushed, by its digest. +func archiveOf(t *testing.T, res BuildResult) []byte { + t.Helper() + raw, ok := pushed.Load(res.Digest) + if !ok { + t.Fatalf("nothing was pushed under %s", res.Digest) + } + return raw.([]byte) +} + +// 1587-S2 (ADR 0498 §1): a done build answers a reference of the stated form whose tail is the build's digest, and the +// store holds the archive there; a bundle and an archive alike. +func TestADoneBuildAnswersTheReferenceOfTheArchiveItPushed(t *testing.T) { + m, states, _ := buildModule(t, manifestWith(goBundle), "the program") + store := withStore(t, m) + res, st := runBuild(t, m, states, "tools") + if st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + if want := "artifact-store://demo/tools/blobs/" + res.Digest; res.Reference != want { + t.Errorf("reference %q, want %q", res.Reference, want) + } + if store.addr != "store.test:5000" || store.blobs["demo/tools@"+res.Digest] == nil { + t.Errorf("the store at %q holds %d blobs, not demo/tools@%s", store.addr, len(store.blobs), res.Digest) + } + a, states2, _ := archiveModule(t, "") + withStore(t, a) + arch, st := runBuild(t, a, states2, "files") + if st.State != delivery.StepPassed || arch.Reference != "artifact-store://demo/files/blobs/"+arch.Digest { + t.Errorf("the archive artifact: %s %q, reference %q", st.State, st.Reason, arch.Reference) + } +} + +// 1587-S3 (ADR 0498 §2): a push the store refuses fails the step `not stored: : `, and so +// does a store whose address the manifest did not place. +func TestARefusedPushFailsTheStepNotStored(t *testing.T) { + m, states, _ := buildModule(t, manifestWith(goBundle), "the program") + store := withStore(t, m) + store.fail = errors.New("store.test:5000 refused the blob: 500 Internal Server Error disk full") + if _, st := runBuild(t, m, states, "tools"); st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, delivery.NotStored+"tools: ") || !strings.Contains(st.Reason, "disk full") { + t.Errorf("the step ended %s: %q", st.State, st.Reason) + } + m.RegistryFile = filepath.Join(t.TempDir(), "absent") + if _, st := runBuild(t, m, states, "tools"); st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, delivery.NotStored+"tools: ") { + t.Errorf("no address: the step ended %s: %q", st.State, st.Reason) + } +} + +// 1587-S4 (the lead's decision L9): the packed file is not left on the node once its push answered, a candidate's +// included: the workspace holds no archive, pushed or refused. +func TestThePackedFileLeavesTheNodeOnceItsPushAnswered(t *testing.T) { + for _, fail := range []error{nil, errors.New("refused")} { + m, states, _ := buildModule(t, manifestWith(goBundle), "the program") + withStore(t, m).fail = fail + runBuild(t, m, states, "tools") + var archives []string + filepath.WalkDir(m.Work, func(p string, d os.DirEntry, err error) error { + if err == nil && strings.HasSuffix(p, ".tar.gz") { + archives = append(archives, p) + } + return nil + }) + if len(archives) != 0 { + t.Errorf("push answered %v: archives left on the node: %v", fail, archives) + } + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go new file mode 100644 index 00000000..1a2fa7ae --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go @@ -0,0 +1,112 @@ +//go:build integration + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/distribution/distribution/v3/configuration" + "github.com/distribution/distribution/v3/registry/handlers" + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// realStore starts the artifact store's own program (the distribution registry, modules/distribution) inside this +// test, with its in-memory storage, on a loopback port, and points m at it with the real push client. It is imported +// by this integration test alone: the module's program does not carry it. +func realStore(t *testing.T, m *Module) string { + t.Helper() + config := &configuration.Configuration{Storage: configuration.Storage{"inmemory": configuration.Parameters{}}} + config.HTTP.Secret = "a throwaway store" + config.Log.Level = "error" + config.Log.AccessLog.Disabled = true + srv := httptest.NewServer(handlers.NewApp(context.Background(), config)) + t.Cleanup(srv.Close) + addr := strings.TrimPrefix(srv.URL, "http://") + m.RegistryFile = filepath.Join(t.TempDir(), "registry") + if err := os.WriteFile(m.RegistryFile, []byte(addr+"\n"), 0o644); err != nil { + t.Fatal(err) + } + m.Push = nil + return addr +} + +// resolve reads a done artifact's reference from the store, as the controller will: artifact-store:/// +// /blobs/ is /v2///blobs/. +func resolve(t *testing.T, addr string, a delivery.BuiltArtifact) []byte { + t.Helper() + path, ok := strings.CutPrefix(a.Reference, "artifact-store://") + if !ok { + t.Fatalf("%s's reference %q is not the artifact store's", a.Name, a.Reference) + } + resp, err := http.Get("http://" + addr + "/v2/" + path) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("the store answered %s for %s", resp.Status, a.Reference) + } + return body +} + +// 1587-S9 (ADR 0498's check): a done build's every artifact names a reference whose tail is its digest, and that +// reference resolves in a real artifact store to bytes of that digest. The build is the real go's, shut off, of the +// fixture testdata/repos/build, with an archive beside its bundle. +func TestADoneBuildsArtifactsResolveInARealArtifactStore(t *testing.T) { + needTools(t, "the build", "go", "bwrap") + work := t.TempDir() + tree := filepath.Join(work, "ws1", "tree") + if err := os.MkdirAll(tree, 0o755); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("cp", "-r", filepath.Join("testdata", "repos", "build")+"/.", tree).CombinedOutput(); err != nil { + t.Fatalf("%v %s", err, out) + } + manifest := `{"module":"fixture-build","version":"1","build":{"artifacts":[` + + `{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/prog","binary":"prog-bin","loads":["prog-bin"]},` + + `{"name":"files","kind":"archive","from":"cmd"}]}}` + if err := os.WriteFile(filepath.Join(tree, "module.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + home := t.TempDir() + m := &Module{Home: home, Work: work, Cache: t.TempDir()} + m.Run = StepRunner(home) + addr := realStore(t, m) + done := delivery.BuilderBuild{V: 1, State: delivery.BuildDone, Manifest: strings.Repeat("0", 64)} + for _, a := range []struct{ name, kind string }{{"tools", "bundle"}, {"files", "archive"}} { + out, err := m.build(context.Background(), map[string]any{"workspace": "ws1", "module": ".", "artifact": a.name}) + if err != nil { + t.Fatalf("%s: %v", a.name, err) + } + r := out.(BuildResult) + done.Artifacts = append(done.Artifacts, delivery.BuiltArtifact{Name: a.name, Kind: a.kind, Digest: r.Digest, Reference: r.Reference}) + } + if _, err := delivery.EncodeBuilderBuild(done); err != nil { + t.Errorf("the done key the builder would write is refused: %v", err) + } + if ok, missing := done.Referenced(); !ok { + t.Errorf("the done key is not deliverable, missing %v", missing) + } + for _, a := range done.Artifacts { + if !strings.HasSuffix(a.Reference, "/blobs/"+a.Digest) { + t.Errorf("%s: the reference %q does not end in its digest %s", a.Name, a.Reference, a.Digest) + } + sum := sha256.Sum256(resolve(t, addr, a)) + if got := "sha256:" + hex.EncodeToString(sum[:]); got != a.Digest { + t.Errorf("%s resolves to %s, its digest is %s", a.Name, got, a.Digest) + } + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test.go b/modules/mesh-build-go/cmd/mesh-build-go/test.go index 3448fbd1..d129c277 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test.go @@ -33,6 +33,7 @@ func (m *Module) testTool() StepTool { "level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"}, "packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "package patterns of each module, ./... when none"}, + "run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"}, }, "required": []string{"workspace", "level"}, }, @@ -56,6 +57,10 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, err } + pattern, _ := args["run"].(string) + if !runPatternOK(pattern) { + return nil, fmt.Errorf("%q is not a go test -run pattern", pattern) + } if !workspaceOK.MatchString(workspace) { return nil, fmt.Errorf("%q is not a workspace id", workspace) } @@ -65,9 +70,9 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { } switch level { case LevelUnit: - return m.runLevel(ctx, tree, levelOpts{level: LevelUnit, packages: packages, bounds: Bounds{Run: m.unitBound()}, env: m.besideEnv(workspace)}) + return m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.besideEnv(workspace)}) case LevelIntegration: - return m.integration(ctx, workspace, tree, packages) + return m.integration(ctx, workspace, tree, packages, pattern) } return nil, fmt.Errorf("the level %q is neither unit nor integration", level) } @@ -103,6 +108,10 @@ func packagesArg(v any) ([]string, error) { // levelOpts are what a level asks of runLevel. type levelOpts struct { + // workspace is the workspace's id: its commands run shut off in it (stepRunner). + workspace string + // run is go test's -run pattern, "" for every test. + run string level string tags []string packages []string @@ -129,6 +138,10 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR if err != nil { return nil, err } + run, err := m.stepRunner(o.workspace, false) + if err != nil { + return nil, err + } began := m.clock() for _, d := range dirs { packages := byDir[d] @@ -139,8 +152,11 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR if len(o.tags) > 0 { args = append(args, "-tags", strings.Join(o.tags, ",")) } + if o.run != "" { + args = append(args, "-run", o.run) + } args = append(args, packages...) - r := m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), o.env...), "go", args...) + r := run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), o.env...), "go", args...) rd := newReader(res, d) noise := rd.read(r.Stdout) rd.finish() @@ -202,3 +218,22 @@ func modulePath(gomod string) string { } return "" } + +// runPatternOK says whether a -run pattern is one go test takes as a single argument: no flag, no space, no control +// character, and short ("" is every test). +func runPatternOK(p string) bool { + if len(p) > 512 || strings.HasPrefix(p, "-") { + return false + } + for _, r := range p { + if r <= ' ' || r == 0x7f { + return false + } + } + if p != "" { + if _, err := regexp.Compile(strings.Split(p, "/")[0]); err != nil { + return false + } + } + return true +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go index 60c2aac7..26c7a75f 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go @@ -21,6 +21,9 @@ func TestMain(m *testing.M) { if len(os.Args) > 1 && os.Args[1] == busArg { os.Exit(runBus(os.Args[2:])) } + if len(os.Args) > 1 && os.Args[1] == levelArg { + os.Exit(runLevelProcess(os.Args[2:])) // and the integration level's shut-off process (level.go) + } os.Exit(m.Run()) } @@ -54,7 +57,7 @@ func repository(t *testing.T, name string) string { // test_test.go read the output captured from them, and this one proves the capture is what the real go says today. It // starts go test, so it carries the tag. func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) { - needTools(t, "the unit level on fixtures", "go", "git") + needTools(t, "the unit level on fixtures", "go", "git", "bwrap") want := map[string]struct { passed, noTests bool failed string @@ -66,11 +69,18 @@ func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) { "broken": {failed: "fixture/broken"}, } for name, w := range want { - tree := repository(t, name) home := t.TempDir() m := &Module{Home: home, Work: t.TempDir(), Cache: t.TempDir()} + // The tree lies in a workspace of the work directory, as a fetch leaves it: the level runs shut off in it (1309). + tree := filepath.Join(m.Work, "ws1", "tree") + if err := os.MkdirAll(filepath.Dir(tree), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Rename(repository(t, name), tree); err != nil { + t.Fatal(err) + } m.Run = StepRunner(home) - res, err := m.runLevel(context.Background(), tree, levelOpts{level: LevelUnit, bounds: Bounds{Run: UnitBound}}) + res, err := m.runLevel(context.Background(), tree, levelOpts{workspace: "ws1", level: LevelUnit, bounds: Bounds{Run: UnitBound}}) if err != nil { t.Fatalf("%s: %v", name, err) } @@ -96,7 +106,7 @@ func processesNamed(text string) []string { // integration level passes, and afterwards no postgres or bus process of the run is left and its directory is gone. // It starts postgres and a bus, so it runs in the merge check and in a try, never on the way. func TestIntegrationLevelRunsAgainstARealStoreAndBusAndLeavesNothing(t *testing.T) { - needTools(t, "the integration level", "go", "git", "initdb", "pg_ctl", "postgres") + needTools(t, "the integration level", "go", "git", "initdb", "pg_ctl", "postgres", "bwrap") tree := repository(t, "store") // A short work directory: a unix socket's path is bounded at 108 bytes. work, err := os.MkdirTemp("/tmp", "w") @@ -113,7 +123,7 @@ func TestIntegrationLevelRunsAgainstARealStoreAndBusAndLeavesNothing(t *testing. home := t.TempDir() m := &Module{Recipe: Current(), Home: home, Work: work, Cache: t.TempDir()} m.Run = StepRunner(home) - res, err := m.integration(context.Background(), "ws1", filepath.Join(work, "ws1", "tree"), nil) + res, err := m.integration(context.Background(), "ws1", filepath.Join(work, "ws1", "tree"), nil, "") if err != nil { t.Fatalf("the integration level: %v", err) } @@ -134,7 +144,7 @@ func TestIntegrationLevelRunsAgainstARealStoreAndBusAndLeavesNothing(t *testing. // 1487/AC2: given a fixture Go module, when it is built twice, both digests are equal and the archive holds one // executable named as binaryName says. It runs the real go, so it carries the tag. func TestBuildTwiceGivesOneDigestAndOneExecutable(t *testing.T) { - needTools(t, "the build", "go") + needTools(t, "the build", "go", "bwrap") work := t.TempDir() tree := filepath.Join(work, "ws1", "tree") if err := os.MkdirAll(tree, 0o755); err != nil { @@ -151,6 +161,7 @@ func TestBuildTwiceGivesOneDigestAndOneExecutable(t *testing.T) { home := t.TempDir() m := &Module{Home: home, Work: work, Cache: t.TempDir()} m.Run = StepRunner(home) + withStore(t, m) args := map[string]any{"workspace": "ws1", "module": ".", "artifact": "tools"} first, err := m.build(context.Background(), args) if err != nil { @@ -164,11 +175,7 @@ func TestBuildTwiceGivesOneDigestAndOneExecutable(t *testing.T) { if a.Digest != b.Digest || a.Binary != "prog-bin" { t.Errorf("two builds of one tree: %+v and %+v", a, b) } - raw, err := os.ReadFile(a.Path) - if err != nil { - t.Fatal(err) - } - zr, err := gzip.NewReader(bytes.NewReader(raw)) + zr, err := gzip.NewReader(bytes.NewReader(archiveOf(t, a))) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go index c13e8085..39cab882 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go @@ -44,7 +44,7 @@ func workspaceFor(t *testing.T, captured string) (*Module, *keptStates, string, states := newStates() m := moduleWith(states, "go1.27.1") m.Home, m.Work, m.Cache = t.TempDir(), t.TempDir(), t.TempDir() - m.Run = capturedRunner(t, captured, calls) + m.Run = throughBubblewrap(t, capturedRunner(t, captured, calls), &[]shutOff{}) // the step's go runs shut off (1309) if err := os.MkdirAll(filepath.Join(m.Work, "ws1", "tree"), 0o755); err != nil { t.Fatal(err) } @@ -185,12 +185,12 @@ func TestUnitLevelRunsGoTestJsonInTheModulesDirectoryWithTheStepsEnvironment(t * // A go test that said nothing a test says (it could not run) ends the step failed, not the level. func TestAGoTestThatCouldNotRunEndsTheStepFailed(t *testing.T) { m, states, ws, _ := workspaceFor(t, "empty") - m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { if name == "go" { return forgekey.Ran{Status: 1, Stderr: "go: cannot find main module\n"} } return forgekey.Ran{Stdout: "go.mod\n"} - } + }, &[]shutOff{}) _, st := runUnit(t, m, states, ws) if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "go: cannot find main module") { t.Errorf("the step ended %s: %q", st.State, st.Reason) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/probe/go.mod b/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/probe/go.mod new file mode 100644 index 00000000..fc941f82 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/probe/go.mod @@ -0,0 +1,3 @@ +module fixture/probe + +go 1.25.0 diff --git a/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/probe/probe_test.go b/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/probe/probe_test.go new file mode 100644 index 00000000..8c5340e7 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/probe/probe_test.go @@ -0,0 +1,34 @@ +package probe + +import ( + "encoding/json" + "net" + "os" + "testing" + "time" +) + +// TestProbe tries what a change's code must not reach, named by PROBE_* (the test that runs it sets them through +// the environment of the step), and writes what it saw to seen.json beside it. +func TestProbe(t *testing.T) { + seen := map[string]bool{} + read := func(p string) bool { _, err := os.ReadFile(p); return err == nil } + seen["key"] = read(os.Getenv("PROBE_KEY")) + seen["login"] = read(os.Getenv("PROBE_LOGIN")) + _, err := net.LookupHost("git.novox.be") + seen["resolved"] = err == nil + reach := func(network, addr string) bool { + c, err := net.DialTimeout(network, addr, 2*time.Second) + if err == nil { + c.Close() + } + return err == nil + } + seen["tcp"] = reach("tcp", os.Getenv("PROBE_TCP")) + seen["socket"] = reach("unix", os.Getenv("PROBE_SOCKET")) + seen["wroteCache"] = os.WriteFile(os.Getenv("PROBE_CACHE")+"/written-by-a-candidate", []byte("x"), 0o644) == nil + raw, _ := json.Marshal(seen) + if err := os.WriteFile("seen.json", raw, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/testdata/upstream-release.tar.xz b/modules/mesh-build-go/cmd/mesh-build-go/testdata/upstream-release.tar.xz new file mode 100644 index 00000000..6b6949c1 Binary files /dev/null and b/modules/mesh-build-go/cmd/mesh-build-go/testdata/upstream-release.tar.xz differ diff --git a/modules/mesh-build-go/cmd/mesh-build-go/xz_framing.go b/modules/mesh-build-go/cmd/mesh-build-go/xz_framing.go new file mode 100644 index 00000000..b6e925a3 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/xz_framing.go @@ -0,0 +1,293 @@ +package main + +// Copied from mesh-controller origin/main internal/builder/xz_framing.go (issue 1590): the build agent's walk of an xz +// file's framing before its reader decodes it. "The build seat" below is the build module now. + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "io" + "os" +) + +// xzBlocksMax and xzStreamsMax are the most blocks and the most concatenated streams an xz file may hold for +// the build seat to read it. +// +// **Why a block count is limited at all.** The xz reader (github.com/therootcompany/xz v1.0.1) keeps one +// filter decoder for every block that names a delta or BCJ filter, and frees them only when a stream ends: +// its block counters are reset per stream, not per block. A stream of many tiny blocks therefore grows the +// heap with its length: an `xz --block-size=1 --delta` file of 6 MB kept about 67 MiB live, which comes to +// about 5.6 GiB at the 512 MiB download cap (review 68 on mesh-controller pull request 264, hq issue 345). +// +// **Why 1,024.** At about 340 bytes of decoder a block, 1,024 blocks keep at most about 0.35 MiB live. A +// real release is far below it: Betterbird's archive is 1 stream with 1 block, and multi-threaded xz cuts +// blocks of three times the dictionary (24 MiB at the default preset), so even 512 MiB unpacked in blocks +// of 512 KiB stays within it. Streams are limited to 16 because a release is one stream; the decoders are +// reused from one stream to the next, so the stream limit only bounds the work of this walk. +const ( + xzBlocksMax = 1024 + xzStreamsMax = 16 +) + +// xzWithinLimits walks an xz file's framing, without decompressing anything, and refuses one holding more +// blocks or streams than the build seat reads. +// +// **The framing, not the index.** Each stream's index at its end lists its blocks, but the reader compares +// the index with the blocks only after it has decoded them all, so a file whose index lies would still be +// decoded block by block. This walk follows the same framing the reader follows: stream header, each block +// header, the block's LZMA2 chunks (each chunk header carries its own compressed size, so a chunk is skipped +// without being decoded), its padding and check, then the index and the footer. It counts the blocks the +// reader would decode, and it also refuses an index whose count of blocks is not the count walked. +func xzWithinLimits(file string) error { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + _, _, err = xzFraming(bufio.NewReader(f)) + if errors.Is(err, errXzTooManyBlocks) || errors.Is(err, errXzTooManyStreams) { + return err + } + if err != nil { + return fmt.Errorf("it is not an xz file this build seat reads: %v", err) + } + return nil +} + +var ( + errXzTooManyBlocks = fmt.Errorf("it holds more than %d xz blocks, the most this build seat reads: "+ + "its xz reader keeps a filter decoder for every block (hq issue 345)", xzBlocksMax) + errXzTooManyStreams = fmt.Errorf("it holds more than %d concatenated xz streams, the most this build "+ + "seat reads", xzStreamsMax) +) + +// xzFraming counts the streams and blocks of an xz file, checking its framing as it goes. It stops with +// errXzTooManyBlocks or errXzTooManyStreams at the first block or stream over the limit. +func xzFraming(r *bufio.Reader) (streams, blocks int, err error) { + full := func(n int) ([]byte, error) { + b := make([]byte, n) + if _, err := io.ReadFull(r, b); err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + return b, nil + } + skip := func(n int) error { + if got, err := r.Discard(n); err != nil || got != n { + return io.ErrUnexpectedEOF + } + return nil + } + for { + header, err := full(12) + if err != nil { + return streams, blocks, fmt.Errorf("stream %d: header: %v", streams+1, err) + } + if !bytes.Equal(header[:6], []byte{0xfd, '7', 'z', 'X', 'Z', 0x00}) { + return streams, blocks, fmt.Errorf("stream %d: no xz stream header", streams+1) + } + if header[6] != 0 || header[7]&0xf0 != 0 || + crc32.ChecksumIEEE(header[6:8]) != binary.LittleEndian.Uint32(header[8:12]) { + return streams, blocks, fmt.Errorf("stream %d: a corrupt stream header", streams+1) + } + if streams++; streams > xzStreamsMax { + return streams, blocks, errXzTooManyStreams + } + checkSize := 0 + if check := int(header[7]); check != 0 { + checkSize = 4 << ((check - 1) / 3) + } + inStream := 0 + for { + first, err := r.ReadByte() + if err != nil { + return streams, blocks, fmt.Errorf("stream %d: no index: %v", streams, io.ErrUnexpectedEOF) + } + if first == 0 { + break // the index indicator + } + rest, err := full(int(first)*4 + 3) + if err != nil { + return streams, blocks, fmt.Errorf("stream %d, block %d: header: %v", streams, inStream+1, err) + } + if err := xzBlockHeader(append([]byte{first}, rest...)); err != nil { + return streams, blocks, fmt.Errorf("stream %d, block %d: %v", streams, inStream+1, err) + } + inStream++ + if blocks++; blocks > xzBlocksMax { + return streams, blocks, errXzTooManyBlocks + } + // The block's LZMA2 chunks, up to the end marker. + size := 0 + for { + control, err := r.ReadByte() + if err != nil { + return streams, blocks, fmt.Errorf("stream %d, block %d: %v", streams, inStream, io.ErrUnexpectedEOF) + } + size++ + if control == 0 { + break + } + switch { + case control == 1 || control == 2: // stored: a 16-bit size less one, then the bytes + b, err := full(2) + if err != nil { + return streams, blocks, err + } + n := int(binary.BigEndian.Uint16(b)) + 1 + if err := skip(n); err != nil { + return streams, blocks, err + } + size += 2 + n + case control >= 0x80: // LZMA: unpacked and packed sizes, the properties byte on a reset + b, err := full(4) + if err != nil { + return streams, blocks, err + } + n := int(binary.BigEndian.Uint16(b[2:])) + 1 + size += 4 + if control >= 0xc0 { + if err := skip(1); err != nil { + return streams, blocks, err + } + size++ + } + if err := skip(n); err != nil { + return streams, blocks, err + } + size += n + default: + return streams, blocks, fmt.Errorf("stream %d, block %d: an LZMA2 chunk of kind %#x", + streams, inStream, control) + } + } + if pad := (4 - size%4) % 4; pad > 0 { + b, err := full(pad) + if err != nil || !bytes.Equal(b, make([]byte, pad)) { + return streams, blocks, fmt.Errorf("stream %d, block %d: corrupt block padding", streams, inStream) + } + } + if err := skip(checkSize); err != nil { + return streams, blocks, err + } + } + // The index: its count of records, two numbers each, padding, CRC32. + used := 1 + count, n, err := xzVLI(r) + if err != nil { + return streams, blocks, fmt.Errorf("stream %d: index: %v", streams, err) + } + used += n + if count != uint64(inStream) { + return streams, blocks, fmt.Errorf("stream %d: its index lists %d blocks, but it holds %d", + streams, count, inStream) + } + for i := 0; i < 2*inStream; i++ { + _, n, err := xzVLI(r) + if err != nil { + return streams, blocks, fmt.Errorf("stream %d: index: %v", streams, err) + } + used += n + } + if err := skip((4-used%4)%4 + 4); err != nil { + return streams, blocks, fmt.Errorf("stream %d: index: %v", streams, err) + } + footer, err := full(12) + if err != nil || footer[10] != 'Y' || footer[11] != 'Z' { + return streams, blocks, fmt.Errorf("stream %d: no stream footer", streams) + } + // Stream padding: zero bytes, a multiple of four, then the end or another stream. + zeros := 0 + for { + b, err := r.ReadByte() + if err == io.EOF { + if zeros%4 != 0 { + return streams, blocks, errors.New("stream padding not a multiple of four bytes") + } + return streams, blocks, nil + } + if err != nil { + return streams, blocks, err + } + if b != 0 { + _ = r.UnreadByte() + break + } + zeros++ + } + if zeros%4 != 0 { + return streams, blocks, errors.New("stream padding not a multiple of four bytes") + } + } +} + +// xzBlockHeader checks one block header: its CRC32, its flags, and that its last filter is LZMA2, the only +// one whose chunks this walk can skip (and the only last filter the reader decodes). +func xzBlockHeader(h []byte) error { + size := len(h) + if crc32.ChecksumIEEE(h[:size-4]) != binary.LittleEndian.Uint32(h[size-4:]) { + return errors.New("a corrupt block header") + } + flags := h[1] + if flags&0x3c != 0 { + return errors.New("a block header with reserved flags") + } + body := bytes.NewReader(h[2 : size-4]) + if flags&0x40 != 0 { + if _, _, err := xzVLI(body); err != nil { + return err + } + } + if flags&0x80 != 0 { + if _, _, err := xzVLI(body); err != nil { + return err + } + } + var last uint64 + for i := 0; i <= int(flags&3); i++ { + id, _, err := xzVLI(body) + if err != nil { + return err + } + props, _, err := xzVLI(body) + if err != nil { + return err + } + if props > uint64(body.Len()) { + return errors.New("a corrupt block header") + } + if _, err := body.Seek(int64(props), io.SeekCurrent); err != nil { + return err + } + last = id + } + if last != 0x21 { + return fmt.Errorf("a block whose last filter is %#x, not LZMA2", last) + } + return nil +} + +// xzVLI reads one of xz's variable-length integers (at most 9 bytes, 7 bits each), and how many bytes it took. +func xzVLI(r io.ByteReader) (uint64, int, error) { + var v uint64 + for i := 0; i < 9; i++ { + b, err := r.ReadByte() + if err != nil { + return 0, i, io.ErrUnexpectedEOF + } + v |= uint64(b&0x7f) << (7 * i) + if b&0x80 == 0 { + if i > 0 && b == 0 { + return 0, i + 1, errors.New("a number not in its shortest form") + } + return v, i + 1, nil + } + } + return 0, 9, errors.New("a number longer than nine bytes") +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/xz_framing_test.go b/modules/mesh-build-go/cmd/mesh-build-go/xz_framing_test.go new file mode 100644 index 00000000..2ee8efd7 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/xz_framing_test.go @@ -0,0 +1,180 @@ +package main + +// Copied from mesh-controller origin/main internal/builder/xz_framing_test.go (issue 1590), with xz_framing.go: the +// build agent's walk of an xz file's framing, which this module now uses as the build agent does. + +import ( + "archive/tar" + "bufio" + "bytes" + "encoding/binary" + "errors" + "hash/crc32" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// xzStream is one xz stream (no integrity check) of data cut into blocks of blockSize bytes, each block's +// LZMA2 data stored uncompressed, each block a delta filter before LZMA2 when delta is set: the shape of +// `xz --block-size=N --filters=delta,lzma2`, built without any xz tool. listed is how many blocks its index +// lists (-1: as many as it holds), so a stream whose index lies can be built too. +func xzStream(data []byte, blockSize int, delta bool, listed int) []byte { + var out bytes.Buffer + flags := []byte{0, 0} + out.Write([]byte{0xfd, '7', 'z', 'X', 'Z', 0x00}) + out.Write(flags) + out.Write(binary.LittleEndian.AppendUint32(nil, crc32.ChecksumIEEE(flags))) + type record struct{ unpadded, uncompressed uint64 } + var records []record + for at := 0; at < len(data); at += blockSize { + block := data[at:min(at+blockSize, len(data))] + if delta { + // Delta-encoded with a distance of one; the decoder's history starts empty at each block. + encoded := make([]byte, len(block)) + for i := range block { + encoded[i] = block[i] + if i > 0 { + encoded[i] -= block[i-1] + } + } + block = encoded + } + header := []byte{2, 0} // 12 bytes + if delta { + header[1] = 1 // two filters + header = append(header, 0x03, 0x01, 0x00) + } + header = append(header, 0x21, 0x01, 0x00) // LZMA2, a 4 KiB dictionary + for len(header) < 8 { + header = append(header, 0) + } + header = binary.LittleEndian.AppendUint32(header, crc32.ChecksumIEEE(header)) + out.Write(header) + size := 0 + for i, chunk := 0, block; len(chunk) > 0; i++ { + n := min(len(chunk), 1<<16) + control := byte(2) + if i == 0 { + control = 1 // the first chunk of a block resets the dictionary + } + out.WriteByte(control) + out.Write(binary.BigEndian.AppendUint16(nil, uint16(n-1))) + out.Write(chunk[:n]) + size += 3 + n + chunk = chunk[n:] + } + out.WriteByte(0) + size++ + out.Write(make([]byte, (4-size%4)%4)) + records = append(records, record{uint64(len(header) + size), uint64(len(block))}) + } + if listed >= 0 { + for len(records) < listed { + records = append(records, records[0]) + } + records = records[:listed] + } + vli := func(b []byte, v uint64) []byte { + for v >= 0x80 { + b = append(b, byte(v)|0x80) + v >>= 7 + } + return append(b, byte(v)) + } + index := vli([]byte{0}, uint64(len(records))) + for _, r := range records { + index = vli(vli(index, r.unpadded), r.uncompressed) + } + for len(index)%4 != 0 { + index = append(index, 0) + } + index = binary.LittleEndian.AppendUint32(index, crc32.ChecksumIEEE(index)) + out.Write(index) + footer := binary.LittleEndian.AppendUint32(nil, uint32(len(index)/4-1)) + footer = append(footer, flags...) + out.Write(binary.LittleEndian.AppendUint32(nil, crc32.ChecksumIEEE(footer))) + out.Write(footer) + out.WriteString("YZ") + return out.Bytes() +} + +// tarHolding is a tar holding betterbird/data with body. +func tarHolding(t *testing.T, body []byte) []byte { + t.Helper() + var out bytes.Buffer + w := tar.NewWriter(&out) + if err := w.WriteHeader(&tar.Header{Name: "betterbird/data", Mode: 0o644, Size: int64(len(body)), + Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + w.Write(body) + if err := w.Close(); err != nil { + t.Fatal(err) + } + return out.Bytes() +} + +func writeTemp(t *testing.T, body []byte) string { + t.Helper() + file := filepath.Join(t.TempDir(), "r.tar.xz") + if err := os.WriteFile(file, body, 0o644); err != nil { + t.Fatal(err) + } + return file +} + +func TestAnXzFileOfManyDeltaBlocksIsRefusedBeforeItIsDecoded(t *testing.T) { + // Review 68 on pull request 264: `xz --block-size=1 --delta` of a 200 kB tar kept about 67 MiB live in + // the reader, one delta decoder per block. Its index here lists one block, as a file crafted to pass a + // check of the index alone would; the walk counts the blocks the reader would decode. + body := make([]byte, 200_000) + for i := range body { + body[i] = byte(i * 7) + } + crafted := xzStream(tarHolding(t, body), 1, true, 1) + path := writeTemp(t, crafted) + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + err := unpackUpstream(path, t.TempDir(), "betterbird") + runtime.ReadMemStats(&after) + if err == nil || !strings.Contains(err.Error(), "more than 1024 xz blocks") { + t.Fatalf("a file of %d bytes in blocks of one byte was not refused for its blocks: %v", len(crafted), err) + } + if grew := after.TotalAlloc - before.TotalAlloc; grew > 4<<20 { + t.Fatalf("%d bytes were allocated for a refused file", grew) + } +} + +func TestTheXzBlockLimitIsExact(t *testing.T) { + data := make([]byte, xzBlocksMax+1) + streams, blocks, err := xzFraming(bufio.NewReader(bytes.NewReader(xzStream(data[:xzBlocksMax], 1, true, -1)))) + if err != nil || streams != 1 || blocks != xzBlocksMax { + t.Fatalf("%d blocks: %d streams, %d blocks, %v", xzBlocksMax, streams, blocks, err) + } + if _, _, err := xzFraming(bufio.NewReader(bytes.NewReader(xzStream(data, 1, true, -1)))); !errors.Is(err, errXzTooManyBlocks) { + t.Fatalf("%d blocks: %v", xzBlocksMax+1, err) + } +} + +func TestAnXzFileOfTooManyStreamsIsRefused(t *testing.T) { + var file []byte + for i := 0; i <= xzStreamsMax; i++ { + file = append(file, xzStream([]byte("x"), 1, false, -1)...) + } + err := unpackUpstream(writeTemp(t, file), t.TempDir(), "") + if err == nil || !strings.Contains(err.Error(), "more than 16 concatenated xz streams") { + t.Fatalf("%v", err) + } +} + +func TestAnXzIndexThatDoesNotListItsBlocksIsRefused(t *testing.T) { + file := xzStream(tarHolding(t, []byte("x")), 512, false, 1) + err := unpackUpstream(writeTemp(t, file), t.TempDir(), "betterbird") + if err == nil || !strings.Contains(err.Error(), "its index lists 1 blocks, but it holds 4") { + t.Fatalf("%v", err) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/xz_test.go b/modules/mesh-build-go/cmd/mesh-build-go/xz_test.go new file mode 100644 index 00000000..d2c6ef39 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/xz_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// releaseXzSHA256 is the sha256 of testdata/upstream-release.tar.xz: the tar stream of upstream-release.tar.gz, +// compressed with `xz -9e` (one stream, one block). +const releaseXzSHA256 = "7e59472615d7d654d6f72e8275c83e36203da9db1b91372dcd38a3ff7984f997" + +// pinnedModuleServing is pinnedModule for any fixture file, served at /. +func pinnedModuleServing(t *testing.T, fixture, sha, extra string) (*Module, *keptStates) { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", fixture)) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/"+fixture { + http.NotFound(w, r) + return + } + w.Write(body) + })) + t.Cleanup(srv.Close) + m, states, _ := archiveModule(t, pinned("https://example.com/"+fixture, sha, extra)) + client := srv.Client() + client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, srv.Listener.Addr().String()) + } + m.HTTP = client + return m, states +} + +// Issue 1590: a pinned release compressed with xz (as betterbird's is) is unpacked as the build agent unpacks it, and +// gives the build agent's archive: the same tar under gzip and under xz packs to one stream. +func TestAPinnedXzArchiveIsUnpackedAsTheBuildAgentDoes(t *testing.T) { + m, states := pinnedModuleServing(t, "upstream-release.tar.xz", releaseXzSHA256, `,"within":"release/app"`) + res, st := runBuild(t, m, states, "files") + if st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %q", st.State, st.Reason) + } + sameAsBuildAgent(t, res, buildAgentWithinStream, buildAgentWithinDigest) +} diff --git a/modules/mesh-build-go/files/registry b/modules/mesh-build-go/files/registry new file mode 100644 index 00000000..1bcadab2 --- /dev/null +++ b/modules/mesh-build-go/files/registry @@ -0,0 +1 @@ +${seat:mesh-artifact-store:reach} diff --git a/modules/mesh-build-go/go.mod b/modules/mesh-build-go/go.mod index 15a8c845..e80b240e 100644 --- a/modules/mesh-build-go/go.mod +++ b/modules/mesh-build-go/go.mod @@ -3,19 +3,81 @@ module meshbuildgo go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 + github.com/distribution/distribution/v3 v3.1.2 github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.54.0 + github.com/therootcompany/xz v1.0.1 ) require ( github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect + github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect + github.com/docker/go-metrics v0.1.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/handlers v1.5.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect github.com/klauspost/compress v1.20.0 // indirect github.com/minio/highwayhash v1.0.4 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/jwt/v2 v2.8.1 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect + github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect + github.com/redis/go-redis/v9 v9.7.3 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect + go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect + go.opentelemetry.io/otel/log v0.21.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/sdk v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.21.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/modules/mesh-build-go/go.sum b/modules/mesh-build-go/go.sum index e35e25ab..83c780bf 100644 --- a/modules/mesh-build-go/go.sum +++ b/modules/mesh-build-go/go.sum @@ -1,17 +1,67 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5 h1:1BiPKRPTYpgex8rSHWT9az0BteOafGWRDzd/Ba6GAcA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= 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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.1.2 h1:/Bv2YIqqSR00HiO49FT93hovy6r9IlrzwUDsJjCN5qg= +github.com/distribution/distribution/v3 v3.1.2/go.mod h1:Oz0Drs2Tx3iTXzK3DhUD4otfTfqlCxlWVC7QJeXWBP0= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-events v0.0.0-20250808211157-605354379745 h1:yOn6Ze6IbYI/KAw2lw/83ELYvZh6hvsygTVkD0dzMC4= +github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.1.0 h1:r76KPNpstz+IvQKSWpYegSkkyzex0V3A1ZGVx6bhGlY= +github.com/docker/go-metrics v0.1.0/go.mod h1:PciI3sONtB051kXALN1JoIlpcu54E1FuPh+4DuqEzyw= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= +github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= +github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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= @@ -22,10 +72,109 @@ github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= +github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= +github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 h1:WseeVYf5dJZTsyPiyW5L14k5qsSibqXAMTSiFEDiWr0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0/go.mod h1:SiLZnQS6Qk2eCpvr2CH/XMAOa64TWGXxEZJZCpD2Lmc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 h1:fvNHGyo3CdRv/DQveXqhqBxnKTDyRaC5sMSQxilX/A0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0/go.mod h1:zyGrjRKL2B/6+Jc/m4/otPoZqV2MY9ZjC/aBraRO7zc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 h1:2lpf4hnrasYIsUyEXwnTZq5lsxrMm4T2Bwb06IctAZQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0/go.mod h1:YWOW6h7jwApz9Pl76ie/izUsSPj0s2MdIlpqbPqaf3U= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA= +go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/log v0.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc= +go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0/go.mod h1:HD1575K8e6sIFBBDd5tZB3t9DlMytWXq9FuR+Y4rfjE= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index 9f0fd7fd..5d801100 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -31,7 +31,8 @@ ], "requires": [ "forge-account", - "go-toolchain" + "go-toolchain", + "artifact-store" ], "own-secrets": { "broker": "${dir:state}/broker" @@ -109,6 +110,14 @@ "mode": "0644", "template": "files/gitconfig" }, + { + "id": "registry", + "type": "file", + "path": "${dir:state}/registry", + "owner": "mesh-build-go", + "mode": "0644", + "template": "files/registry" + }, { "id": "git", "type": "package", @@ -119,6 +128,11 @@ "type": "package", "package": "openssh" }, + { + "id": "bubblewrap", + "type": "package", + "package": "bubblewrap" + }, { "id": "postgresql", "type": "package", diff --git a/modules/mesh-build-image/README.md b/modules/mesh-build-image/README.md index 9ebca6cb..e25dd475 100644 --- a/modules/mesh-build-image/README.md +++ b/modules/mesh-build-image/README.md @@ -30,6 +30,10 @@ The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can re `/:` (the first 12 hex digits of the digest unless the call gives one), waiting out a registry held still, and the key carries the digest the registry serves and the reference pinned by it. Without `publish` (a pull request) nothing is pushed. +- `build_image_copy` copies an upstream artifact (`module`, `artifact`, `from`) registry to registry into the artifact store + over the registry API, the whole index and every blob, by the digest the manifest pins (ADR 0498 §4), and verifies the + store serves that digest. Answers the digest and the reference `//@sha256:`. A + `from` without a digest fails `not pinned:`. It needs no workspace and no buildah. - It refuses, naming why, an image whose build context is a whole repository (`context`) or whose `build.on` names another module's artifact (`unsupportedImage` in `build.go`): those forms belong to the images build-agent builds until it is removed at R4g. diff --git a/modules/mesh-build-image/cmd/mesh-build-image/announce.go b/modules/mesh-build-image/cmd/mesh-build-image/announce.go new file mode 100644 index 00000000..616e78c1 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/announce.go @@ -0,0 +1,19 @@ +package main + +// What this module keeps on the bus, announced from its code (novox/hq ADR 0495 §2 §5; issue 1790): every state it +// keeps, each readable by others, since another module reads each today (the builder reads steps; gitea reads keys). A state of its own that it +// did not name here is refused at the call (stdio.Keeps). + +import ( + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/announce" +) + +// Kept are the states the module keeps. +var Kept = []announce.State{ + {Name: "keys", Readable: true}, + {Name: "steps", Readable: true}, +} + +// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves. +func keepStates() error { return stdio.Keeps(Kept...) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/announce_integration_test.go b/modules/mesh-build-image/cmd/mesh-build-image/announce_integration_test.go new file mode 100644 index 00000000..0c20faef --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/announce_integration_test.go @@ -0,0 +1,63 @@ +//go:build integration + +package main + +import ( + "bufio" + "encoding/json" + "io" + "testing" + + "github.com/nats-io/nats.go" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/announcetest" +) + +// 1790 (ADR 0495 §5): served as the runtime serves it, the module announces on the bus every state it keeps, as its +// code declares them. The runtime's part, publishing what the bundle asks to publish, is played by relay. +func TestTheModuleAnnouncesEveryStateItKeeps(t *testing.T) { + const module = "mesh-build-image" + announcetest.Check(t, module, Kept, func(url string) func() { + t.Setenv("MESH_MODULE", module) + t.Setenv("MESH_NODE", "shanks") + nc, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + if err := keepStates(); err != nil { + t.Fatal(err) + } + inR, inW := io.Pipe() + outR, outW := io.Pipe() + go func() { _ = stdio.ServeOn(module, nil, inR, outW) }() + go relay(t, module, nc, outR, inW) + return func() { _ = inW.Close(); _ = outW.Close(); nc.Close() } + }) +} + +// relay is the runtime's side of the channel: each mesh/publish the bundle asks is published on its module's event +// subject, mesh.mod..event., and answered. +func relay(t *testing.T, module string, nc *nats.Conn, from io.Reader, to io.Writer) { + sc := bufio.NewScanner(from) + sc.Buffer(make([]byte, 1<<20), 1<<24) + enc := json.NewEncoder(to) + for sc.Scan() { + var m struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Key string `json:"key"` + Body json.RawMessage `json:"body"` + } `json:"params"` + } + if json.Unmarshal(sc.Bytes(), &m) != nil || m.Method != "mesh/publish" { + continue + } + if err := nc.Publish("mesh.mod."+module+".event."+m.Params.Key, m.Params.Body); err != nil { + t.Errorf("publishing %s: %v", m.Params.Key, err) + } + _ = nc.Flush() + _ = enc.Encode(map[string]any{"id": m.ID, "result": map[string]any{}}) + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/build.go b/modules/mesh-build-image/cmd/mesh-build-image/build.go index 2e149852..e138fd75 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/build.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/build.go @@ -74,6 +74,23 @@ func unsupportedImage(manifest catalogue.Manifest, a catalogue.Artifact) string return "" } +// qualifyImage names a base image in full, as the controller's parseReference reads it: a name without a registry host +// is the public hub's (docker.io), a single name under `library/`. buildah, with this machine's registry configuration, +// has no unqualified-search registry and refuses a short name (issue 1824); a name that has a host is kept. +func qualifyImage(ref string) string { + name := ref + if at := strings.Index(name, "@"); at >= 0 { + name = name[:at] + } + if slash := strings.Index(name, "/"); slash >= 0 && (strings.ContainsAny(name[:slash], ".:") || name[:slash] == "localhost") { + return ref + } + if !strings.Contains(name, "/") { + return "docker.io/library/" + ref + } + return "docker.io/" + ref +} + // imageArgs are the --build-arg words of the images the manifest pins in build.on, in its order. func imageArgs(manifest catalogue.Manifest) []string { var out []string @@ -82,7 +99,7 @@ func imageArgs(manifest catalogue.Manifest) []string { } for _, on := range manifest.Build.On { if on.Image != "" && on.Arg != "" { - out = append(out, "--build-arg", on.Arg+"="+on.Image) + out = append(out, "--build-arg", on.Arg+"="+qualifyImage(on.Image)) } } return out @@ -109,7 +126,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, fmt.Errorf("no module in %q of workspace %s: %w", module, workspace, err) } - manifest, err := catalogue.ParseManifest(raw) + manifest, err := catalogue.ParseManifestForInputs(raw) if err != nil { return nil, fmt.Errorf("the manifest in %q: %w", module, err) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/build_test.go b/modules/mesh-build-image/cmd/mesh-build-image/build_test.go index 46fadba6..966c3adc 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/build_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/build_test.go @@ -198,3 +198,53 @@ func TestBuildFailsForWhatIsNotAnImageOrNotThere(t *testing.T) { } } } + +// Issue 1751 (ADR 0514 §2, the review's K2): a manifest whose config value falls back to a predefined key builds. The +// builder holds no published list of the keys, so it reads the manifest for its inputs (ParseManifestForInputs), not +// as a judge of config values. The manifest is claude-code's real one (capturedClaudeCodeManifest) with the image fixture's +// base and artifact added to its build, since no image module of the catalogue falls back to a predefined key today. +func TestAManifestFallingBackToAPredefinedKeyBuilds(t *testing.T) { + raw := []byte(capturedClaudeCodeManifest) + if !strings.Contains(string(raw), `"predefined": "agent-managed-settings"`) { + t.Fatal("the captured manifest no longer falls back to a predefined key") + } + m, _, states := setup(t, func(fixture string) string { + var real, image map[string]json.RawMessage + if err := json.Unmarshal(raw, &real); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(fixture), &image); err != nil { + t.Fatal(err) + } + // The image fixture's base and artifact join claude-code's own artifacts (its archive of files/ stays). + var realBuild, imageBuild struct { + On json.RawMessage `json:"on,omitempty"` + Artifacts []json.RawMessage `json:"artifacts"` + } + if err := json.Unmarshal(real["build"], &realBuild); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(image["build"], &imageBuild); err != nil { + t.Fatal(err) + } + realBuild.On = imageBuild.On + realBuild.Artifacts = append(realBuild.Artifacts, imageBuild.Artifacts...) + b, err := json.Marshal(realBuild) + if err != nil { + t.Fatal(err) + } + real["build"] = b + out, err := json.Marshal(real) + if err != nil { + t.Fatal(err) + } + return string(out) + }) + res, st := runBuild(t, m, states, nil) + if st.State != delivery.StepPassed { + t.Fatalf("step = %+v", st) + } + if res.Digest != imageDigest || res.Artifact != "server" { + t.Errorf("result = %+v", res) + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/captured_manifest_test.go b/modules/mesh-build-image/cmd/mesh-build-image/captured_manifest_test.go new file mode 100644 index 00000000..69ee6aef --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/captured_manifest_test.go @@ -0,0 +1,257 @@ +package main + +// capturedClaudeCodeManifest is modules/claude-code/module.json of this catalogue at 56c68be7, copied verbatim on +// 2026-10-22 for issue 1751 (the R4e review's finding K2): a real manifest whose config value managed_settings falls +// back to the predefined key agent-managed-settings (ADR 0348 §7), which the builder holds no published list for. It +// lives in a test file, not under testdata, because the catalogue's paths check (ADR 0260) reads every other file of a +// module and the manifest names claude-code's own paths. +const capturedClaudeCodeManifest = `{ + "module": "claude-code", + "version": "1", + "moved": true, + "slug": "agent", + "capabilities": [ + "package-manager" + ], + "requires": [ + "mcp-endpoint", + { + "name": "gitea-read", + "optional": true + } + ], + "binds": { + "mcp-endpoint": "${dir:state}/mcp-endpoint.json" + }, + "secrets": { + "gitea-read": "${dir:state}/gitea-read.secret" + }, + "uses": [ + "operator-channel" + ], + "state": [ + "servers", + "holdings", + "config", + "proposals", + "questions", + "memory", + "waits" + ], + "reads": [ + "claude-licence-manager.bindings" + ], + "tools": [ + "claude_code_status", + "claude_code_render", + "claude_code_guard", + "claude_code_pull", + "claude_code_grant", + "claude_code_add_api_key", + "claude_code_registrations", + "claude_code_memory", + "claude_code_mcp_list", + "claude_code_mcp_register", + "claude_code_mcp_unregister", + "claude_code_skill_list", + "claude_code_skill_register", + "claude_code_skill_unregister", + "claude_code_agent_list", + "claude_code_agent_register", + "claude_code_agent_unregister", + "claude_code_command_list", + "claude_code_command_register", + "claude_code_command_unregister", + "claude_code_hook_list", + "claude_code_hook_register", + "claude_code_hook_unregister", + "claude_code_output_style_list", + "claude_code_output_style_register", + "claude_code_output_style_unregister", + "claude_code_instructions_list", + "claude_code_instructions_register", + "claude_code_instructions_unregister", + "claude_code_instructions_propose", + "claude_code_proposals", + "claude_code_ask", + "claude_code_answer", + "claude_code_wait", + "claude_code_settings_get", + "claude_code_settings_set", + "claude_code_settings_clear", + "claude_code_permission_add", + "claude_code_permission_remove", + "claude_code_config_list", + "claude_code_config_show", + "claude_code_config_status", + "claude_code_config_import", + "claude_code_home_show", + "claude_code_home_remove", + "claude_code_home_removed", + "claude_code_home_restore", + "claude_code_judge", + "claude_code_judged" + ], + "data": { + "own": [ + { + "id": "agent-home", + "path": "${dir:agent-home}", + "class": "valuable", + "why": "the operator's agent's own files: its memory, history and projects" + } + ] + }, + "config": { + "role": { + "type": "string", + "description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.", + "trusted": true, + "default": "" + }, + "mcp_servers": { + "type": "object", + "description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.", + "trusted": true, + "default": {} + }, + "managed_settings": { + "type": "object", + "description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.", + "trusted": true, + "predefined": "agent-managed-settings", + "default": {} + }, + "instructions": { + "type": "object", + "description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + }, + "output_styles": { + "type": "object", + "description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + }, + "skills": { + "type": "object", + "description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.", + "trusted": true, + "default": {} + }, + "commands": { + "type": "object", + "description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + }, + "agents": { + "type": "object", + "description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.", + "trusted": true, + "default": {} + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "claude-code" + }, + { + "id": "managed", + "type": "directory", + "path": "/etc/claude-code", + "mode": "0755", + "unknown": "program" + }, + { + "id": "start", + "type": "file", + "path": "/usr/local/bin/claude-agent", + "mode": "0755", + "template": "files/claude-agent" + }, + { + "id": "agent-account-name", + "type": "file", + "path": "/etc/claude-code/agent-account", + "mode": "0644", + "template": "files/agent-account" + }, + { + "id": "agent-account", + "type": "user", + "name": "${machine:agent-account}", + "home": "${machine:agent-home}", + "root": "${machine:agent-root}" + }, + { + "id": "agent-home", + "type": "directory", + "path": "${machine:agent-home}/.claude", + "mode": "0700", + "owner": "${machine:agent-account}", + "unknown": "module" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "${machine:account}", + "place": ".", + "unknown": "program" + }, + { + "id": "facts", + "type": "file", + "path": "${dir:state}/facts.json", + "mode": "0600", + "owner": "${machine:account}", + "template": "files/facts.json" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "${machine:account}", + "takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"], + "merge": "json", + "template": "files/settings.json" + } + ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/claude-code", + "binary": "claude-code", + "loads": [ + "claude-code" + ], + "env": { + "MESH_CLAUDE_CODE_STATE": "${dir:state}", + "MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json", + "MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} +` diff --git a/modules/mesh-build-image/cmd/mesh-build-image/copy.go b/modules/mesh-build-image/cmd/mesh-build-image/copy.go new file mode 100644 index 00000000..6c88dc08 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/copy.go @@ -0,0 +1,374 @@ +package main + +// The copy step (novox/hq ADR 0498 §4; issue 1587): an upstream artifact is copied registry to registry into the +// artifact store, whole (an index with every manifest it lists, every blob), by the digest its manifest pins. The copy +// runs no code of a change, so it needs neither buildah nor an isolation; it is made over the registry API, which +// keeps every digest as it is (a pull and a push through buildah may rewrite a manifest). An upstream without a +// digest is not copied: `not pinned:`. A copy that does not end with the pinned digest in the store is `not stored:`. + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// CopyTool is the copy step's tool. +const CopyTool = "build_image_copy" + +// CopyResult is what a passed copy step says: the pinned digest, now in the store, and the reference to it in the +// form an image's is (//@sha256:). +type CopyResult struct { + Artifact string `json:"artifact"` + Digest string `json:"digest"` + Reference string `json:"reference"` + Pushed bool `json:"pushed"` +} + +var ( + nameOK = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$`) + pinnedOK = regexp.MustCompile(`@(sha256:[0-9a-f]{64})$`) + manifestKind = strings.Join([]string{ + "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json"}, ", ") +) + +func (m *Module) copyTool() StepTool { + return StepTool{ + Name: CopyTool, + Description: "Copy an upstream image, registry to registry, into the artifact store by the digest the manifest pins " + + "(novox/hq ADR 0498 §4). Answers a step id at once; the step ends on its key with the digest in the store and the " + + "reference to it. A reference without a digest fails `not pinned:`. Needs no workspace and runs no code of a change.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "module": map[string]any{"type": "string", "description": "the module that names the upstream artifact"}, + "artifact": map[string]any{"type": "string", "description": "the upstream artifact's name in the module's manifest"}, + "from": map[string]any{"type": "string", "description": "the image reference the manifest gives, pinned by digest, such as nats:2.11.17-alpine@sha256:..."}, + }, + "required": []string{"module", "artifact", "from"}, + }, + Long: true, + Run: m.copy, + } +} + +// upstreamRef is an image reference split the way a container runtime does: no host means the public hub. +type upstreamRef struct { + base, repository, digest string +} + +func parseUpstream(from string) (upstreamRef, error) { + pin := pinnedOK.FindStringSubmatch(from) + if pin == nil { + return upstreamRef{}, fmt.Errorf("%q names no digest", from) + } + name := strings.TrimSuffix(from, "@"+pin[1]) + if colon := strings.LastIndex(name, ":"); colon > strings.LastIndex(name, "/") { + name = name[:colon] // the tag is only what it was called; the digest names the image + } + if name == "" { + return upstreamRef{}, fmt.Errorf("%q is not an image reference", from) + } + host, repository := "docker.io", name + if slash := strings.Index(name, "/"); slash >= 0 && (strings.ContainsAny(name[:slash], ".:") || name[:slash] == "localhost") { + host, repository = name[:slash], name[slash+1:] + } + if host == "docker.io" { + host = "registry-1.docker.io" + if !strings.Contains(repository, "/") { + repository = "library/" + repository + } + } + scheme := "https://" + if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.") { + scheme = "http://" + } + return upstreamRef{base: scheme + host, repository: repository, digest: pin[1]}, nil +} + +func (m *Module) copy(ctx context.Context, args map[string]any) (any, error) { + module, _ := args["module"].(string) + name, _ := args["artifact"].(string) + from, _ := args["from"].(string) + if !nameOK.MatchString(module) || !nameOK.MatchString(name) { + return nil, fmt.Errorf("%q / %q: a module and an artifact are named in lower case letters, digits, dots, dashes and underscores", module, name) + } + src, err := parseUpstream(from) + if err != nil { + return nil, fmt.Errorf("not pinned: %s: %v; an upstream artifact is copied by the digest its manifest pins", name, err) + } + addr, err := m.registry() + if err != nil { + return nil, fmt.Errorf("not stored: %s: %w", name, err) + } + repository := module + "/" + name + c := &copier{m: m, src: src, dstBase: "http://" + addr, dstRepo: repository, done: map[string]bool{}} + if err := m.waitForRegistry(ctx, addr, "copying "+from, func() error { return c.copyManifest(ctx, src.digest) }); err != nil { + return nil, fmt.Errorf("not stored: %s: %w", name, err) + } + if err := c.verify(ctx); err != nil { + return nil, fmt.Errorf("not stored: %s: %w", name, err) + } + return CopyResult{Artifact: name, Digest: src.digest, Reference: addr + "/" + repository + "@" + src.digest, Pushed: true}, nil +} + +// copier copies one image from its upstream registry into the store's repository. +type copier struct { + m *Module + src upstreamRef + dstBase string + dstRepo string + token string + done map[string]bool +} + +func (c *copier) client() *http.Client { + if c.m.HTTP != nil { + return c.m.HTTP + } + return &http.Client{Timeout: 30 * time.Minute} +} + +// get reads from the upstream registry, answering one bearer challenge with an anonymous token. +func (c *copier) get(ctx context.Context, path, accept string) (*http.Response, error) { + for attempt := 0; attempt < 2; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.src.base+"/v2/"+c.src.repository+path, nil) + if err != nil { + return nil, err + } + if accept != "" { + req.Header.Set("Accept", accept) + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.client().Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusUnauthorized && attempt == 0 { + challenge := resp.Header.Get("Www-Authenticate") + resp.Body.Close() + if c.token, err = c.fetchToken(ctx, challenge); err != nil { + return nil, err + } + continue + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("upstream answered %s for %s%s", resp.Status, c.src.repository, path) + } + return resp, nil + } + return nil, fmt.Errorf("upstream refused %s%s after a token", c.src.repository, path) +} + +var challengeParam = regexp.MustCompile(`(\w+)="([^"]*)"`) + +func (c *copier) fetchToken(ctx context.Context, challenge string) (string, error) { + if !strings.HasPrefix(strings.ToLower(challenge), "bearer ") { + return "", fmt.Errorf("upstream asks for credentials (%q) and this copy holds none", challenge) + } + p := map[string]string{} + for _, kv := range challengeParam.FindAllStringSubmatch(challenge, -1) { + p[kv[1]] = kv[2] + } + realm := p["realm"] + if realm == "" { + return "", fmt.Errorf("upstream's challenge %q names no realm", challenge) + } + q := url.Values{"service": {p["service"]}, "scope": {"repository:" + c.src.repository + ":pull"}} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, realm+"?"+q.Encode(), nil) + if err != nil { + return "", err + } + resp, err := c.client().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var tok struct { + Token string `json:"token"` + Access string `json:"access_token"` + } + if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&tok) != nil || (tok.Token == "" && tok.Access == "") { + return "", fmt.Errorf("upstream's token service answered %s", resp.Status) + } + if tok.Token != "" { + return tok.Token, nil + } + return tok.Access, nil +} + +// copyManifest copies the manifest of digest and everything it names, children before the manifest itself. +func (c *copier) copyManifest(ctx context.Context, digest string) error { + if c.done[digest] { + return nil + } + resp, err := c.get(ctx, "/manifests/"+digest, manifestKind) + if err != nil { + return err + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + resp.Body.Close() + if err != nil { + return err + } + sum := sha256.Sum256(body) + if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest { + return fmt.Errorf("upstream served %s for the manifest pinned as %s", got, digest) + } + var doc struct { + MediaType string `json:"mediaType"` + Config struct{ Digest string } `json:"config"` + Layers []struct{ Digest string } `json:"layers"` + Manifests []struct{ Digest string } `json:"manifests"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return fmt.Errorf("the manifest %s is not JSON: %w", digest, err) + } + for _, child := range doc.Manifests { + if err := c.copyManifest(ctx, child.Digest); err != nil { + return err + } + } + blobs := []string{} + if doc.Config.Digest != "" { + blobs = append(blobs, doc.Config.Digest) + } + for _, l := range doc.Layers { + blobs = append(blobs, l.Digest) + } + for _, b := range blobs { + if err := c.copyBlob(ctx, b); err != nil { + return err + } + } + mediaType := resp.Header.Get("Content-Type") + if mediaType == "" { + mediaType = doc.MediaType + } + if err := c.putManifest(ctx, digest, mediaType, body); err != nil { + return err + } + c.done[digest] = true + return nil +} + +func (c *copier) copyBlob(ctx context.Context, digest string) error { + if !digestOK.MatchString(digest) { + return fmt.Errorf("%q is not a blob digest", digest) + } + if c.done[digest] { + return nil + } + head, err := http.NewRequestWithContext(ctx, http.MethodHead, c.dstBase+"/v2/"+c.dstRepo+"/blobs/"+digest, nil) + if err != nil { + return err + } + if r, err := c.client().Do(head); err != nil { + return err + } else { + r.Body.Close() + if r.StatusCode == http.StatusOK { + c.done[digest] = true + return nil + } + } + post, err := http.NewRequestWithContext(ctx, http.MethodPost, c.dstBase+"/v2/"+c.dstRepo+"/blobs/uploads/", nil) + if err != nil { + return err + } + pr, err := c.client().Do(post) + if err != nil { + return err + } + pr.Body.Close() + loc, err := pr.Location() + if pr.StatusCode != http.StatusAccepted || err != nil { + return fmt.Errorf("the store answered %s to a blob upload of %s", pr.Status, digest) + } + q := loc.Query() + q.Set("digest", digest) + loc.RawQuery = q.Encode() + blob, err := c.get(ctx, "/blobs/"+digest, "") + if err != nil { + return err + } + defer blob.Body.Close() + put, err := http.NewRequestWithContext(ctx, http.MethodPut, loc.String(), blob.Body) + if err != nil { + return err + } + put.ContentLength = blob.ContentLength + put.Header.Set("Content-Type", "application/octet-stream") + resp, err := c.client().Do(put) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("the store answered %s to the blob %s", resp.Status, digest) + } + c.done[digest] = true + return nil +} + +func (c *copier) putManifest(ctx context.Context, digest, mediaType string, body []byte) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.dstBase+"/v2/"+c.dstRepo+"/manifests/"+digest, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", mediaType) + resp, err := c.client().Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return fmt.Errorf("the store answered %s to the manifest %s", resp.Status, digest) + } + return nil +} + +// verify reads back what the store serves for the pinned digest: only the store can say. +func (c *copier) verify(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.dstBase+"/v2/"+c.dstRepo+"/manifests/"+c.src.digest, nil) + if err != nil { + return err + } + req.Header.Set("Accept", manifestKind) + resp, err := c.client().Do(req) + if err != nil { + return err + } + resp.Body.Close() + if got := resp.Header.Get("Docker-Content-Digest"); resp.StatusCode != http.StatusOK || got != c.src.digest { + return fmt.Errorf("the store answered %s with digest %q for the pinned %s", resp.Status, got, c.src.digest) + } + return nil +} + +// Plain is a long step that needs nothing of the machine: no guard. The copy runs no buildah. +func (m *Module) Plain(tool string, run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { + id, err := buildstep.Start(m.Ctx, m.Steps, m.Node, tool, func(ctx context.Context) (any, error) { return run(ctx, args) }) + if err != nil { + return nil, err + } + key, _ := delivery.StepKey(m.Node, id) + return map[string]any{"step": id, "key": key, "state": delivery.StepRunning}, nil + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/copy_test.go b/modules/mesh-build-image/cmd/mesh-build-image/copy_test.go new file mode 100644 index 00000000..d546dde0 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/copy_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func digestOf(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) } + +// memRegistry is a registry in memory: blobs and manifests by digest, per repository. +type memRegistry struct { + mu sync.Mutex + blobs map[string][]byte // repo|digest + manifests map[string][]byte // repo|digest + types map[string]string + puts []string + srv *httptest.Server +} + +func newMem(t *testing.T) *memRegistry { + r := &memRegistry{blobs: map[string][]byte{}, manifests: map[string][]byte{}, types: map[string]string{}} + r.srv = httptest.NewServer(http.HandlerFunc(r.serve)) + t.Cleanup(r.srv.Close) + return r +} + +func (r *memRegistry) addr() string { return r.srv.Listener.Addr().String() } + +func (r *memRegistry) serve(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + defer r.mu.Unlock() + path := strings.TrimPrefix(req.URL.Path, "/v2/") + if i := strings.Index(path, "/blobs/uploads/"); i >= 0 { + if req.Method == http.MethodPost { + w.Header().Set("Location", "/v2/"+path[:i]+"/blobs/uploads/u1") + w.WriteHeader(http.StatusAccepted) + return + } + body, _ := io.ReadAll(req.Body) + d := req.URL.Query().Get("digest") + if digestOf(body) != d { + w.WriteHeader(http.StatusBadRequest) + return + } + r.blobs[path[:i]+"|"+d] = body + r.puts = append(r.puts, "blob "+d) + w.WriteHeader(http.StatusCreated) + return + } + for _, kind := range []string{"blobs", "manifests"} { + if i := strings.Index(path, "/"+kind+"/"); i >= 0 { + key := path[:i] + "|" + path[i+len(kind)+2:] + store := r.blobs + if kind == "manifests" { + store = r.manifests + } + if req.Method == http.MethodPut { + body, _ := io.ReadAll(req.Body) + if digestOf(body) != path[i+len(kind)+2:] { + w.WriteHeader(http.StatusBadRequest) + return + } + r.manifests[key] = body + r.types[key] = req.Header.Get("Content-Type") + r.puts = append(r.puts, "manifest "+path[i+len(kind)+2:]) + w.WriteHeader(http.StatusCreated) + return + } + body, ok := store[key] + if !ok { + http.NotFound(w, req) + return + } + w.Header().Set("Docker-Content-Digest", path[i+len(kind)+2:]) + w.Header().Set("Content-Type", r.types[key]) + if req.Method == http.MethodHead { + return + } + w.Write(body) + return + } + } + http.NotFound(w, req) +} + +// upstreamImage puts a two-platform index (two manifests, each with a config and a layer) into r and answers the index digest. +func upstreamImage(r *memRegistry, repo string) string { + var children []map[string]string + for _, tag := range []string{"amd64", "arm64"} { + cfg, layer := []byte(`{"arch":"`+tag+`"}`), []byte("layer-"+tag) + r.blobs[repo+"|"+digestOf(cfg)], r.blobs[repo+"|"+digestOf(layer)] = cfg, layer + mf, _ := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": map[string]string{"digest": digestOf(cfg)}, "layers": []map[string]string{{"digest": digestOf(layer)}}}) + r.manifests[repo+"|"+digestOf(mf)] = mf + r.types[repo+"|"+digestOf(mf)] = "application/vnd.oci.image.manifest.v1+json" + children = append(children, map[string]string{"digest": digestOf(mf)}) + } + idx, _ := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": children}) + d := digestOf(idx) + r.manifests[repo+"|"+d] = idx + r.types[repo+"|"+d] = "application/vnd.oci.image.index.v1+json" + return d +} + +func runCopy(t *testing.T, m *Module, states *keptStates, args map[string]any) (CopyResult, delivery.Step) { + t.Helper() + if _, err := tool(t, m, CopyTool)(args); err != nil { + t.Fatal(err) + } + st := states.waitEnd(t) + var res CopyResult + if st.State == delivery.StepPassed { + if err := json.Unmarshal(st.Result, &res); err != nil { + t.Fatal(err) + } + } + return res, st +} + +// ADR 0498 §4 / AC: the copy leaves the pinned digest in the store and answers it, with the whole index (every +// manifest it lists, every blob), and runs no buildah. +func TestTheCopyLeavesThePinnedDigestInTheStoreAndAnswersIt(t *testing.T) { + up, store := newMem(t), newMem(t) + digest := upstreamImage(up, "library/nats") + f := newFake(t) + states := newStates() + m := moduleWith(t, f, states) + m.HTTP = nil + reg := store.addr() + writeFile(t, m.RegistryFile, reg+"\n") + from := up.addr() + "/library/nats:2.11.17-alpine@" + digest + res, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": from}) + if st.State != delivery.StepPassed { + t.Fatalf("step = %+v", st) + } + if res.Digest != digest || res.Reference != reg+"/nats/server@"+digest || !res.Pushed || res.Artifact != "server" { + t.Errorf("result = %+v", res) + } + if _, ok := store.manifests["nats/server|"+digest]; !ok { + t.Fatal("the index is not in the store") + } + if len(store.manifests) != 3 || len(store.blobs) != 4 { + t.Errorf("store holds %d manifests and %d blobs, want 3 and 4", len(store.manifests), len(store.blobs)) + } + if len(f.lines()) != 0 { + t.Errorf("the copy ran commands: %v", f.lines()) + } + // A second copy writes nothing new but the manifests. + store.puts = nil + if _, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": from}); st.State != delivery.StepPassed { + t.Fatalf("again: %+v", st) + } + for _, p := range store.puts { + if strings.HasPrefix(p, "blob ") { + t.Errorf("a blob the store holds was sent again: %s", p) + } + } +} + +// ADR 0498 §4 / AC: an upstream without a digest fails `not pinned:`, and nothing is read from the upstream. +func TestAnUpstreamWithoutADigestFailsNotPinned(t *testing.T) { + up, store := newMem(t), newMem(t) + upstreamImage(up, "library/nats") + f := newFake(t) + states := newStates() + m := moduleWith(t, f, states) + writeFile(t, m.RegistryFile, store.addr()+"\n") + _, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": up.addr() + "/library/nats:2.11.17-alpine"}) + if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "not pinned: server") { + t.Fatalf("step = %+v", st) + } + if len(store.puts) != 0 { + t.Errorf("the store was written: %v", store.puts) + } +} + +// A manifest the upstream serves under another digest than the pin is not copied; the failure is `not stored:`. +func TestAnUpstreamServingAnotherManifestThanPinnedIsNotStored(t *testing.T) { + up, store := newMem(t), newMem(t) + d := upstreamImage(up, "library/nats") + up.manifests["library/nats|"+d] = []byte(`{"tampered":true}`) + f := newFake(t) + states := newStates() + m := moduleWith(t, f, states) + writeFile(t, m.RegistryFile, store.addr()+"\n") + _, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": up.addr() + "/library/nats@" + d}) + if st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, "not stored: server:") { + t.Fatalf("step = %+v", st) + } + if len(store.manifests) != 0 { + t.Errorf("the store holds %v", store.manifests) + } +} + +func TestAShortNameIsQualifiedAsTheHubsAndAHostIsKept(t *testing.T) { + d := "sha256:" + strings.Repeat("a", 64) + for from, want := range map[string]upstreamRef{ + "alpine@" + d: {"https://registry-1.docker.io", "library/alpine", d}, + "nats:2.11.17-alpine@" + d: {"https://registry-1.docker.io", "library/nats", d}, + "grafana/grafana:11@" + d: {"https://registry-1.docker.io", "grafana/grafana", d}, + "ghcr.io/org/app:1@" + d: {"https://ghcr.io", "org/app", d}, + "localhost:5000/x/y@" + d: {"http://localhost:5000", "x/y", d}, + } { + got, err := parseUpstream(from) + if err != nil || got != want { + t.Errorf("%s = %+v, %v; want %+v", from, got, err, want) + } + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/main.go b/modules/mesh-build-image/cmd/mesh-build-image/main.go index 732e2d2a..24a3d193 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/main.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/main.go @@ -47,6 +47,11 @@ func main() { go m.Start(ctx) } go startKeeper(ctx) + // Every state it keeps, said on the bus before it serves (ADR 0495 §2; issue 1790). + if err := keepStates(); err != nil { + logf("announcing the states it keeps: %v", err) + os.Exit(1) + } if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil { logf("%v", err) os.Exit(1) diff --git a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go index be79ad76..5fa56c06 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go @@ -154,9 +154,6 @@ func TestGiteaReadsTheImageBuildModulesKeys(t *testing.T) { t.Errorf("gitea reads %v, lacks %s", gitea.Reads, want) } } - if problems := catalogue.StateReadsNothingDeclares(all); len(problems) > 0 { - t.Errorf("a read names a state its owner does not keep: %v", problems) - } } // 1704b: over the real manifests, mesh-build-image where no module provides rootless-image-build is refused, the error diff --git a/modules/mesh-build-image/cmd/mesh-build-image/module.go b/modules/mesh-build-image/cmd/mesh-build-image/module.go index 9af9cfbc..c6d869c6 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/module.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/module.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "net/http" - "os" - "path/filepath" "time" "git.novox.be/novox/mesh-sdk/go/buildstep" @@ -60,6 +58,11 @@ type Look struct { // missing is a *buildstep.Refusal naming it, so a step ends refused and the health says the same. func (m *Module) look(ctx context.Context) (Look, error) { l := Look{Account: m.Account} + // The runtime directory comes first: buildah refuses before anything else when its XDG_RUNTIME_DIR is not there + // (issue 1884), so a look that made it only after `buildah --version` never made it at all. + if err := m.ensureRuntimeDir(); err != nil { + return l, &buildstep.Refusal{Text: err.Error()} + } var err error if l.Buildah, err = m.buildahVersion(ctx); err != nil { return l, &buildstep.Refusal{Text: err.Error()} @@ -67,11 +70,6 @@ func (m *Module) look(ctx context.Context) (Look, error) { if l.UIDs, l.GIDs, err = m.ranges(); err != nil { return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah} } - for _, d := range []string{m.Store, filepath.Join(m.Store, "xdg")} { - if err := os.MkdirAll(d, 0o700); err != nil { - return l, err - } - } if err := m.userNamespace(ctx); err != nil { return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah} } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/probe.go b/modules/mesh-build-image/cmd/mesh-build-image/probe.go index 3b2a587d..e96c2212 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/probe.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/probe.go @@ -111,6 +111,25 @@ func (m *Module) storeFlags() []string { // env is what buildah is given beyond the base environment: a runtime directory of its own inside the store, so // nothing is read from the account's runtime directory, and no registry credential file anywhere (the module has none // and REGISTRY_AUTH_FILE is never set). +// +// Every call makes the runtime directory first if it is not there (issue 1884): a directory removed after the last look +// is made again before buildah runs. A failure to make it is not lost: buildah then names the directory in its own +// error, and the next look refuses with it. func (m *Module) env() []string { - return []string{"XDG_RUNTIME_DIR=" + filepath.Join(m.Store, "xdg")} + _ = m.ensureRuntimeDir() + return []string{"XDG_RUNTIME_DIR=" + m.runtimeDir()} +} + +// runtimeDir is buildah's runtime directory, inside the store. +func (m *Module) runtimeDir() string { return filepath.Join(m.Store, "xdg") } + +// ensureRuntimeDir makes the store and the runtime directory inside it, 0700 and under the module's own account (the +// process runs as it), when they are not there; it changes nothing that is. +func (m *Module) ensureRuntimeDir() error { + for _, d := range []string{m.Store, m.runtimeDir()} { + if err := os.MkdirAll(d, 0o700); err != nil { + return fmt.Errorf("buildah's runtime directory %s cannot be made: %w", m.runtimeDir(), err) + } + } + return nil } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/qualify_test.go b/modules/mesh-build-image/cmd/mesh-build-image/qualify_test.go new file mode 100644 index 00000000..4f78cc6c --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/qualify_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const pin = "@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00" + +// 1824: a short base image name is qualified the way the controller's parseReference does (the public hub, `library/` +// for a single name), because buildah has no unqualified-search registry here; a qualified name is left as it is. +func TestAShortBaseImageNameIsQualifiedAsTheHubsAndAQualifiedOneIsKept(t *testing.T) { + for in, want := range map[string]string{ + "nats" + pin: "docker.io/library/nats" + pin, + "nats:2.11.17-alpine" + pin: "docker.io/library/nats:2.11.17-alpine" + pin, + "n8nio/n8n" + pin: "docker.io/n8nio/n8n" + pin, + "docker.io/library/alpine" + pin: "docker.io/library/alpine" + pin, + "ghcr.io/org/app:1" + pin: "ghcr.io/org/app:1" + pin, + "localhost:5000/x/y" + pin: "localhost:5000/x/y" + pin, + "registry-1.docker.io/library/a": "registry-1.docker.io/library/a", + } { + if got := qualifyImage(in); got != want { + t.Errorf("%s = %s, want %s", in, got, want) + } + } +} + +func TestTheBuildArgsCarryTheQualifiedName(t *testing.T) { + m := catalogue.Manifest{Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "NATS_BASE", Image: "nats" + pin}}}} + if got, want := imageArgs(m), []string{"--build-arg", "NATS_BASE=docker.io/library/nats" + pin}; !slices.Equal(got, want) { + t.Errorf("%v, want %v", got, want) + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/runtimedir_test.go b/modules/mesh-build-image/cmd/mesh-build-image/runtimedir_test.go new file mode 100644 index 00000000..d2445774 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/runtimedir_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" + "git.novox.be/novox/mesh-sdk/go/health" +) + +// buildahLike refuses as buildah does when the XDG_RUNTIME_DIR it is given is not there (seen on ace, issue 1884): +// "lstat : no such file or directory", before it does anything else. +func buildahLike(t *testing.T, run forgekey.Runner, seen *[]string) forgekey.Runner { + t.Helper() + return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + if name == "buildah" { + for _, e := range env { + dir, ok := strings.CutPrefix(e, "XDG_RUNTIME_DIR=") + if !ok { + continue + } + if _, err := os.Lstat(dir); err != nil { + *seen = append(*seen, "missing") + return forgekey.Ran{Status: 125, Stderr: "Error: lstat " + dir + ": no such file or directory\n"} + } + *seen = append(*seen, "present") + } + } + return run(ctx, env, name, args...) + } +} + +// 1884/AC1: given a store directory that exists without its runtime directory, when the health is looked at, then the +// module makes the runtime directory (0700) before it runs buildah, and the health is healthy. +func TestHealthMakesTheRuntimeDirectoryBeforeBuildah(t *testing.T) { + f := newFake(t) + m := moduleWith(t, f, newStates()) + var seen []string + m.Run = buildahLike(t, m.Run, &seen) + xdg := filepath.Join(m.Store, "xdg") + if _, err := os.Stat(xdg); !os.IsNotExist(err) { + t.Fatalf("the test needs a store without its runtime directory: %v", err) + } + a := m.Health(context.Background()) + if a.State != health.StateHealthy || !a.Healthy { + t.Fatalf("health = %+v, want healthy", a) + } + st, err := os.Stat(xdg) + if err != nil { + t.Fatalf("the runtime directory was not made: %v", err) + } + if st.Mode().Perm() != 0o700 || !st.IsDir() { + t.Errorf("the runtime directory is %v, want a directory 0700", st.Mode()) + } + for _, s := range seen { + if s != "present" { + t.Fatalf("buildah ran without its runtime directory: %v", seen) + } + } +} + +// 1884/AC2: given a module whose runtime directory was removed after its last look, when a build step runs buildah, then +// the runtime directory is made again before buildah runs. +func TestEveryBuildahRunMakesTheRuntimeDirectoryAgain(t *testing.T) { + f := newFake(t) + m := moduleWith(t, f, newStates()) + var seen []string + m.Run = buildahLike(t, m.Run, &seen) + if err := m.Check(context.Background()); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(m.Store, "xdg")); err != nil { + t.Fatal(err) + } + seen = nil + m.remove("localhost/x:y") + if len(seen) != 1 || seen[0] != "present" { + t.Fatalf("buildah's runs saw their runtime directory %v, want [present]", seen) + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/tools.go b/modules/mesh-build-image/cmd/mesh-build-image/tools.go index a1f0cc0e..f0ebf98a 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/tools.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/tools.go @@ -25,14 +25,18 @@ type StepTool struct { } // StepTools are the module's step tools. -func (m *Module) StepTools() []StepTool { return []StepTool{m.fetchTool(), m.buildTool()} } +func (m *Module) StepTools() []StepTool { + return []StepTool{m.fetchTool(), m.buildTool(), m.copyTool()} +} // Tools is every tool the module serves: its step tools behind the guard, its read tools, its status and its health. func (m *Module) Tools(steps []StepTool) []stdio.Tool { tools := []stdio.Tool{} for _, s := range steps { t := stdio.Tool{Name: s.Name, Description: s.Description, Input: s.Input} - if s.Long { + if s.Name == CopyTool { + t.Run = m.Plain(s.Name, s.Run) // a copy needs no buildah, so the buildah guard does not stand in front of it + } else if s.Long { t.Run = m.Long(s.Name, s.Run) } else { t.Run = m.Short(s.Run) diff --git a/modules/mesh-build-image/go.mod b/modules/mesh-build-image/go.mod index ca0bfead..2e2014fe 100644 --- a/modules/mesh-build-image/go.mod +++ b/modules/mesh-build-image/go.mod @@ -3,7 +3,7 @@ module meshbuildimage go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-build-image/go.sum b/modules/mesh-build-image/go.sum index 3515fb85..db1ea18c 100644 --- a/modules/mesh-build-image/go.sum +++ b/modules/mesh-build-image/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= 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= diff --git a/modules/mesh-build-image/module.json b/modules/mesh-build-image/module.json index 2f9ecb9c..2cee8afa 100644 --- a/modules/mesh-build-image/module.json +++ b/modules/mesh-build-image/module.json @@ -14,6 +14,7 @@ "tools": [ "build_image_fetch", "build_image_build", + "build_image_copy", "build_image_step", "build_image_steps", "build_image_status", diff --git a/modules/mesh-build-node/README.md b/modules/mesh-build-node/README.md index 515bdb82..f2cfce7e 100644 --- a/modules/mesh-build-node/README.md +++ b/modules/mesh-build-node/README.md @@ -58,6 +58,15 @@ toolchain install: a long step answers at once with its id and ends on `.< other `@novox/` packages install. A module with packages and no lock does not fail the fetch: its result lists it under `refused` with the reason, and a build of that bundle fails naming it (issue 1459). Every command runs through `forgekey.ExecRunnerClean(0)` with PATH, HOME, the locale and its own words, nothing of this process's environment. Workspaces older than a day are removed at each fetch. + The arguments are mesh-sdk's `buildstep.FetchRequest` (ADR 0499 §4): with `dir` and `paths` (from the builder, never + from a manifest) the workspace is narrowed to those paths, the manifest and the lock files before anything else. A + member is built against the SDK version its own lock names (the lead's decision L10 of R4e, ADR 0500 §5-§6): the + fetch places that version in the module's `node_modules/@novox/mesh-sdk`, from the group's package when the call + names one (`package: {repository, commit, ref}`: fetched, its dependencies installed from its own lock, built and + packed shut off, published nowhere) and its version and integrity are the lock's, else from the registry (integrity + checked); a version the registry lacks fails `waits for: @novox/mesh-sdk@` (the builder queues the key), or, + with a group package of another version, fails naming both. A module whose lock names no SDK keeps the toolchain's. + A module whose artifact is a `package` gets its dependencies, devDependencies included, from its own lock instead. - `build_node_test` {workspace, module, level} (issue 1491): the unit level is the type-check (the toolchain's tsc, the package's `tsconfig.json` or the build agent's flags over its .ts files) and the `test` script; the integration level is the `test:integration` script, the type-check being its setup. Bounds (the Go module's; ADR 0445 names none for @@ -70,4 +79,14 @@ toolchain install: a long step answers at once with its id and ends on `.< toolchain's packages (not typescript or esbuild) copied to `stage-/node_modules`: the build agent's depth (`/app/modules/module`, `/app/node_modules`), so esbuild's `../../node_modules/...` path comments, and the bundle's digest, equal its. Copies, not links, because the mesh makes no symlink. A bundle that keeps an external gets them in its own `node_modules`. - The packed archive is written beside the tree as `.tar.gz`. + Once the build ended, this process pushes the packed archive to the artifact store (novox/hq ADR 0498 §1 §2, mesh-sdk's + `buildstep.PushBlob`, the address in `${dir:state}/registry`) under `/` by its digest, a candidate's as a + trunk build's, and answers `reference: artifact-store:////blobs/`; a push that fails fails the + step `not stored: : `. The packed bytes never stay on the node. tsc and esbuild, like the + type-check and the test script, run shut off in bubblewrap (ADR 0448): no network, none of the account, the toolchain + read-only, the npm cache beneath a throwaway layer unless the step is told `publish` (a trunk build). + A `package` artifact (ADR 0498 §5) is built by its own `build` script and packed with `npm pack --ignore-scripts`, both + shut off; its digest is the packed file's. Only with `publish` is it published, by this process with the registry's + write credential (never in a workspace or a step's mounts): a version the registry lacks is published, the same + packed file already there is done without a publish, another file under that version fails `version taken: + @`; the reference `@` is answered only then. A candidate answers its digest alone. diff --git a/modules/mesh-build-node/cmd/mesh-build-node/announce.go b/modules/mesh-build-node/cmd/mesh-build-node/announce.go new file mode 100644 index 00000000..2543242e --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/announce.go @@ -0,0 +1,20 @@ +package main + +// What this module keeps on the bus, announced from its code (novox/hq ADR 0495 §2 §5; issue 1790): every state it +// keeps, each readable by others, since another module reads each today (the builder, the checker and the indexer read toolchains; the builder and the checker read steps; gitea reads keys). A state of its own that it +// did not name here is refused at the call (stdio.Keeps). + +import ( + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/announce" +) + +// Kept are the states the module keeps. +var Kept = []announce.State{ + {Name: "toolchains", Readable: true}, + {Name: "keys", Readable: true}, + {Name: "steps", Readable: true}, +} + +// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves. +func keepStates() error { return stdio.Keeps(Kept...) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/announce_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/announce_integration_test.go new file mode 100644 index 00000000..2b1acae8 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/announce_integration_test.go @@ -0,0 +1,63 @@ +//go:build integration + +package main + +import ( + "bufio" + "encoding/json" + "io" + "testing" + + "github.com/nats-io/nats.go" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/announcetest" +) + +// 1790 (ADR 0495 §5): served as the runtime serves it, the module announces on the bus every state it keeps, as its +// code declares them. The runtime's part, publishing what the bundle asks to publish, is played by relay. +func TestTheModuleAnnouncesEveryStateItKeeps(t *testing.T) { + const module = "mesh-build-node" + announcetest.Check(t, module, Kept, func(url string) func() { + t.Setenv("MESH_MODULE", module) + t.Setenv("MESH_NODE", "shanks") + nc, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + if err := keepStates(); err != nil { + t.Fatal(err) + } + inR, inW := io.Pipe() + outR, outW := io.Pipe() + go func() { _ = stdio.ServeOn(module, nil, inR, outW) }() + go relay(t, module, nc, outR, inW) + return func() { _ = inW.Close(); _ = outW.Close(); nc.Close() } + }) +} + +// relay is the runtime's side of the channel: each mesh/publish the bundle asks is published on its module's event +// subject, mesh.mod..event., and answered. +func relay(t *testing.T, module string, nc *nats.Conn, from io.Reader, to io.Writer) { + sc := bufio.NewScanner(from) + sc.Buffer(make([]byte, 1<<20), 1<<24) + enc := json.NewEncoder(to) + for sc.Scan() { + var m struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Key string `json:"key"` + Body json.RawMessage `json:"body"` + } `json:"params"` + } + if json.Unmarshal(sc.Bytes(), &m) != nil || m.Method != "mesh/publish" { + continue + } + if err := nc.Publish("mesh.mod."+module+".event."+m.Params.Key, m.Params.Body); err != nil { + t.Errorf("publishing %s: %v", m.Params.Key, err) + } + _ = nc.Flush() + _ = enc.Encode(map[string]any{"id": m.ID, "result": map[string]any{}}) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build.go b/modules/mesh-build-node/cmd/mesh-build-node/build.go index a5f9519a..3c6e5776 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build.go @@ -18,12 +18,9 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" ) -// launcherSuffix is what an entrypoint's launcher is called beside it: index.js is started as index.serve.mjs -// (novox/hq ADR 0193). -const launcherSuffix = ".serve.mjs" - // Out is where a bundle's compiled files go in the module's directory (builder toolchain.go:118). func Out(artifact string) string { return ".mesh-build/" + artifact } @@ -32,7 +29,10 @@ const banner = "--banner:js=import { createRequire as __meshRequire } from 'node // artifact is the part of a module.json artifact the build reads. type artifact struct { + // module is the manifest's module name, which the artifact is stored under. + module string Name string `json:"name"` + Kind string `json:"kind"` Language string `json:"language"` Entrypoints []string `json:"entrypoints"` External []string `json:"external"` @@ -46,9 +46,10 @@ type BuildResult struct { // Digest is "sha256:" and the hex of the archive's bytes (buildstep.Pack). Digest string `json:"digest"` Bytes int `json:"bytes"` - // Archive is the packed file, in the workspace beside its tree. - Archive string `json:"archive"` - Files []string `json:"files"` + // Reference is where the packed bundle is in the artifact store (novox/hq ADR 0498 §1): + // artifact-store:////blobs/. The packed file never stays on the node. + Reference string `json:"reference"` + Files []string `json:"files"` } func (m *Module) buildTool() StepTool { @@ -63,6 +64,7 @@ func (m *Module) buildTool() StepTool { "workspace": map[string]any{"type": "string", "description": "the workspace id a fetch step answered"}, "module": map[string]any{"type": "string", "description": "the module's directory in the workspace, '.' when it is the root"}, "artifact": map[string]any{"type": "string", "description": "the name of the bundle in the module's module.json"}, + "publish": map[string]any{"type": "boolean", "description": "true for a merged trunk commit alone: the build writes the shared cache; without it (a candidate) the cache is read beneath a throwaway layer"}, }, "required": []string{"workspace", "artifact"}, }, @@ -75,6 +77,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { workspace, _ := args["workspace"].(string) module, _ := args["module"].(string) name, _ := args["artifact"].(string) + publish, _ := args["publish"].(bool) root, dir, err := m.moduleDirOf(workspace, module) if err != nil { return nil, err @@ -83,6 +86,9 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, err } + if a.Kind == "package" { + return m.buildPackage(ctx, workspace, module, root, dir, a, publish) + } if why, err := lockProblem(dir, module); err != nil || why != "" { if err == nil { err = fmt.Errorf("%s was refused by the fetch: %s", name, why) @@ -112,14 +118,14 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { tsc := []string{m.tsc(), "--module", "NodeNext", "--moduleResolution", "NodeNext", "--target", "ES2022", "--rootDir", ".", "--outDir", out} tsc = append(tsc, sourcesFor(a.Entrypoints, out, ".ts")...) - if why := ranFailed(m.runIn(ctx, dir, nil, "node", tsc...)); why != "" { + if why := ranFailed(m.runIn(ctx, root, publish, dir, nil, "node", tsc...)); why != "" { return nil, fmt.Errorf("tsc for %s: %s", a.Name, why) } launchers, err := writeLaunchers(filepath.Join(dir, out), a) if err != nil { return nil, err } - if err := m.bundle(ctx, dir, a, launchers); err != nil { + if err := m.bundle(ctx, root, publish, dir, a, launchers); err != nil { return nil, err } bundleDir := filepath.Join(dir, final) @@ -132,9 +138,10 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, fmt.Errorf("packing %s: %w", a.Name, err) } - file := filepath.Join(root, a.Name+".tar.gz") - if err := os.WriteFile(file, archive, 0o600); err != nil { - return nil, err + // Pushed from this process once the build ended, a candidate's as a trunk build's (ADR 0498 §1 §2): the step answers + // the reference, or fails not stored. The packed bytes are never written to the node (the lead's decision L9). + if err := m.push(ctx, a.module+"/"+a.Name, digest, archive); err != nil { + return nil, fmt.Errorf("%s%s: %v", delivery.NotStored, a.Name, err) } files := []string{} _ = filepath.WalkDir(bundleDir, func(p string, d os.DirEntry, err error) error { @@ -145,12 +152,13 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { return nil }) sort.Strings(files) - return BuildResult{Workspace: workspace, Module: module, Artifact: a.Name, Digest: digest, Bytes: len(archive), Archive: file, Files: files}, nil + return BuildResult{Workspace: workspace, Module: module, Artifact: a.Name, Digest: digest, Bytes: len(archive), + Reference: buildstep.BlobReference(a.module, a.Name, digest), Files: files}, nil } // bundle makes every entrypoint and every launcher ONE file, with esbuild (builder.go:1971-2039): an entrypoint is // bundled in place under its own name, a launcher keeps its name with the extension .mjs. -func (m *Module) bundle(ctx context.Context, dir string, a artifact, launchers map[string]string) error { +func (m *Module) bundle(ctx context.Context, root string, publish bool, dir string, a artifact, launchers map[string]string) error { out, final := Out(a.Name), Out(a.Name)+".bundled" if err := os.RemoveAll(filepath.Join(dir, final)); err != nil { return err @@ -177,7 +185,7 @@ func (m *Module) bundle(ctx context.Context, dir string, a artifact, launchers m return nil } args := append(append(append([]string{}, entries...), common...), extra...) - if why := ranFailed(m.runIn(ctx, dir, nil, m.esbuild(), args...)); why != "" { + if why := ranFailed(m.runIn(ctx, root, publish, dir, nil, m.esbuild(), args...)); why != "" { return fmt.Errorf("esbuild for %s: %s", a.Name, why) } return nil @@ -204,15 +212,12 @@ func (m *Module) bundle(ctx context.Context, dir string, a artifact, launchers m } // writeLaunchers writes, beside every entrypoint, an executable that imports the entrypoint and serves what it -// registered over MCP on stdio (builder.go:1926-1956; novox/hq ADR 0193). Its answer is each entrypoint's launcher, -// relative to the bundle's root. +// registered over MCP on stdio (builder.go:1926-1956; novox/hq ADR 0193). Which entrypoint gets which launcher is +// mesh-sdk's one rule, buildstep.Launchers, which the controller reads a bundle by (R4e). Its answer is each +// entrypoint's launcher, relative to the bundle's root. func writeLaunchers(root string, a artifact) (map[string]string, error) { out := map[string]string{} - for _, entry := range a.Entrypoints { - if !strings.HasSuffix(entry, ".js") { - continue - } - launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix + for entry, launcher := range buildstep.Launchers(a.Language, a.Entrypoints) { body := "#!/usr/bin/env node\n" + "// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" + "// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" + @@ -260,7 +265,8 @@ func readArtifact(dir, name string) (artifact, error) { return artifact{}, fmt.Errorf("the module has no module.json: %w", err) } var m struct { - Build struct { + Module string `json:"module"` + Build struct { Artifacts []artifact `json:"artifacts"` } `json:"build"` } @@ -271,12 +277,17 @@ func readArtifact(dir, name string) (artifact, error) { if a.Name != name { continue } + if a.Kind == "package" && a.Language == "typescript" { + a.module = m.Module + return a, nil // a package is built by its own build script and packed (package.go), from no entrypoint + } if a.Language != "typescript" { return artifact{}, fmt.Errorf("artifact %q is %q, and this module builds typescript", name, a.Language) } if len(a.Entrypoints) == 0 { return artifact{}, fmt.Errorf("artifact %q names no entrypoints", name) } + a.module = m.Module return a, nil } return artifact{}, fmt.Errorf("module.json declares no artifact %q", name) diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go index a7faa3d6..e4c9e5b8 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go @@ -17,7 +17,7 @@ import ( // registry for @novox, which reads without a credential) and makes two workspaces holding one fixture bundle. func realMachine(t *testing.T) (*Module, string) { t.Helper() - for _, p := range []string{"node", "npm"} { + for _, p := range []string{"node", "npm", "bwrap"} { // bwrap: tsc, esbuild and the scripts run shut off (1309) if _, err := exec.LookPath(p); err != nil { t.Skipf("NOT RUN: no %s installed where this check runs (the mesh's Go toolchain image holds none); proven live on a build node", p) } @@ -39,6 +39,7 @@ func realMachine(t *testing.T) (*Module, string) { Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}, Run: run, Registry: func() (Registry, error) { return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil }}} + withStore(t, m) if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil { t.Fatal(err) } @@ -89,3 +90,28 @@ func TestTheFixturePassesTheUnitLevel(t *testing.T) { t.Errorf("result = %+v", r) } } + +// Issue 1869: a package whose test imports its own build output (as mesh-sdk's do) passes the unit level: the test step +// emits it with the real tsc, shut off, before the test script runs. +func TestAPackageWhoseTestsImportItsBuildPassesTheUnitLevel(t *testing.T) { + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("NOT RUN: no bubblewrap installed where this check runs; every build node has it (the module declares it)") + } + m, work := realMachine(t) + for p, body := range map[string]string{ + "modules/p/package.json": `{"name":"p","type":"module","scripts":{"build":"tsc","test":"node --test test/a.test.mjs","prepare":"tsc"}}`, + "modules/p/tsconfig.json": `{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ES2022","outDir":"dist","rootDir":"src"},"include":["src"]}`, + "modules/p/src/index.ts": "export const answer: number = 42;\n", + "modules/p/test/a.test.mjs": "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { answer } from '../dist/index.js';\n" + + "test('the build is there', () => assert.equal(answer, 42));\n", + } { + write(t, filepath.Join(work, "w1", "tree", filepath.FromSlash(p)), body, 0o644) + } + out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/p", "level": "unit"}) + if err != nil { + t.Fatal(err) + } + if r := out.(*TestResult); !r.Passed || !slices.Equal(r.Ran, []string{"build", "test"}) { + t.Errorf("result = %+v", r) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build_test.go b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go index e59aefb2..0165c9bd 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go @@ -6,12 +6,14 @@ import ( "compress/gzip" "context" "io" + "maps" "os" "path/filepath" "slices" "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -96,12 +98,10 @@ func TestTscAndEsbuildRunWithTheBuildAgentsFlags(t *testing.T) { } } -func readArchive(t *testing.T, path string) map[string]os.FileMode { +// readArchive reads the archive a build pushed (ADR 0498: the packed file never stays on the node). +func readArchive(t *testing.T, r BuildResult) map[string]os.FileMode { t.Helper() - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } + raw := archiveOf(t, r) zr, err := gzip.NewReader(bytes.NewReader(raw)) if err != nil { t.Fatal(err) @@ -135,7 +135,7 @@ func TestTheBundleIsPackedWithItsLauncherAndPackageJson(t *testing.T) { if want := []string{"package.json", "tools/index.js", "tools/index.serve.mjs"}; !slices.Equal(r.Files, want) { t.Errorf("files = %v, want %v", r.Files, want) } - modes := readArchive(t, r.Archive) + modes := readArchive(t, r) if len(modes) != 3 || modes["tools/index.serve.mjs"]&0o111 == 0 { t.Errorf("archive = %v, want the three files with the launcher executable", modes) } @@ -143,7 +143,7 @@ func TestTheBundleIsPackedWithItsLauncherAndPackageJson(t *testing.T) { if err != nil || again.(BuildResult).Digest != r.Digest { t.Errorf("a second build: %v, digest %v, want %s", err, again, r.Digest) } - launcher, _ := os.ReadFile(filepath.Join(filepath.Dir(r.Archive), "stage-tools", "modules", "module", ".mesh-build", "tools", "tools", "index.serve.mjs")) + launcher, _ := os.ReadFile(filepath.Join(m.Work, "w1", "stage-tools", "modules", "module", ".mesh-build", "tools", "tools", "index.serve.mjs")) if !strings.Contains(string(launcher), `import { serveRegisteredOverStdio } from "@novox/mesh-sdk/stdio";`) || !strings.Contains(string(launcher), `await import("./index.js");`) { t.Errorf("launcher = %q", launcher) } @@ -181,7 +181,7 @@ func TestThePackagesAreCopiedBesideTheTreeAndIntoTheBundleOnlyForExternals(t *te t.Errorf("the toolchain's own %s was copied", p) } } - for f := range readArchive(t, out.(BuildResult).Archive) { + for f := range readArchive(t, out.(BuildResult)) { if strings.HasPrefix(f, "node_modules") { t.Errorf("a bundle without externals carries %s", f) } @@ -254,3 +254,16 @@ func TestTheBuildRunsWithThePackagesTwoLevelsAboveTheModule(t *testing.T) { } } } + +// R4e: the launchers a build writes are mesh-sdk's one rule (buildstep.Launchers), the names the controller reads a bundle +// by; an entrypoint that is not JavaScript gets none. +func TestTheLaunchersAreTheSDKsRule(t *testing.T) { + a := artifact{Language: "typescript", Entrypoints: []string{"tools/index.js", "web/server.js", "assets/x.css"}} + got, err := writeLaunchers(t.TempDir(), a) + if err != nil { + t.Fatal(err) + } + if want := buildstep.Launchers(a.Language, a.Entrypoints); !maps.Equal(got, want) || len(got) != 2 { + t.Errorf("launchers %v, mesh-sdk's rule %v", got, want) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/check.go b/modules/mesh-build-node/cmd/mesh-build-node/check.go index 672a3539..1bf8c379 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/check.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/check.go @@ -138,18 +138,25 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { return nil } if len(sources) > 0 { + // A package that builds (a build or prepare script, which the fetch's --ignore-scripts never runs) is emitted + // by tsc into the workspace, shut off like every run step, so its tests find what they import of its own output + // (issue 1869: mesh-sdk's tests import ../dist); any other is type-checked without emitting. + name, line := "type-check", m.typeCheck(dir, sources) + if emit := m.emitBuild(dir, scripts); emit != nil { + name, line = "build", emit + } start := m.now() - r := m.runIn(ctx, dir, nil, "node", m.typeCheck(dir, sources)...) + r := m.runIn(ctx, root, false, dir, nil, "node", line...) check = m.now().Sub(start) - if err := ran("type-check", r); err != nil { + if err := ran(name, r); err != nil { return nil, err } - res.Ran = append(res.Ran, "type-check") + res.Ran = append(res.Ran, name) } if hasScript && len(failed) == 0 { start := m.now() env := []string{"PATH=" + binDir(root) + ":" + pathOrDefault(), "npm_config_cache=" + filepath.Join(m.Toolchain.Paths.Cache, "npm")} - r := m.runIn(ctx, dir, env, "npm", scriptArgs(script)...) + r := m.runIn(ctx, root, false, dir, env, "npm", scriptArgs(script)...) run = m.now().Sub(start) if err := ran(script, r); err != nil { return nil, err @@ -220,6 +227,18 @@ func (m *Module) typeCheck(dir string, sources []string) []string { "--target", "ES2022", "--rootDir", "."}, sources...) } +// emitBuild is tsc's line that builds a package as its tsconfig.json says (`tsc -p .`, emitting where it says), when the +// package builds (a build or prepare script) and has a tsconfig.json; nil otherwise. +func (m *Module) emitBuild(dir string, scripts map[string]string) []string { + if scripts["build"] == "" && scripts["prepare"] == "" { + return nil + } + if _, err := os.Stat(filepath.Join(dir, "tsconfig.json")); err != nil { + return nil + } + return []string{m.tsc(), "-p", "."} +} + // packageScripts are the scripts of the package.json in dir (none without one). func packageScripts(dir string) (map[string]string, error) { raw, err := os.ReadFile(filepath.Join(dir, "package.json")) diff --git a/modules/mesh-build-node/cmd/mesh-build-node/check_test.go b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go index dc3e79fa..5a95a94a 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/check_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -122,8 +123,9 @@ func TestTheStepsCommandsGetAnEnvironmentOfTheirOwn(t *testing.T) { t.Errorf("%s was given %s", joined(c), e) } } - if len(c.env) < 3 || !strings.HasPrefix(c.env[0], "PATH=") || !strings.HasPrefix(c.env[1], "HOME=") { - t.Errorf("%s env = %v, want PATH, HOME and the locale", c.name, c.env) + // 1309: the HOME of a run step is its own, inside its /tmp, never the account's. + if len(c.env) < 3 || !strings.HasPrefix(c.env[0], "PATH=") || !slices.Contains(c.env, "HOME=/tmp/home") || !slices.Contains(c.env, "LC_ALL=C") { + t.Errorf("%s env = %v, want PATH, the step's own HOME and the locale", c.name, c.env) } } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/emit_build_test.go b/modules/mesh-build-node/cmd/mesh-build-node/emit_build_test.go new file mode 100644 index 00000000..b13a4daf --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/emit_build_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "slices" + "strings" + "testing" +) + +// The package.json of mesh-sdk's TypeScript package (feat/r4e-sdk 9cb4d59), whose tests import its own build output +// (test/retire.test.ts imports ../dist/...): built by `tsc` (build and prepare), which a fetch with --ignore-scripts +// never runs. +const sdkPackageJSON = `{"name":"@novox/mesh-sdk","version":"0.1.15","type":"module","exports":{".":"./dist/index.js"},"files":["dist"],` + + `"scripts":{"build":"tsc","test":"node --test --experimental-strip-types 'test/*.test.ts'","prepare":"tsc"}}` + +// Issue 1869: a package that builds (a build or prepare script) is emitted by tsc into the workspace, inside the shut-off +// run step, at both levels, before its tests, which import what it emits; the result names it "build". A package that +// does not build is still type-checked without emitting. +func TestAPackageThatBuildsIsEmittedBeforeItsTests(t *testing.T) { + for _, level := range []string{"unit", "integration"} { + m, f, work := newMachine(t) + pkg := sdkPackageJSON + if level == "integration" { + pkg = strings.Replace(pkg, `"prepare":"tsc"`, `"prepare":"tsc","test:integration":"node --test test/it.mjs"`, 1) + } + workspaceWith(t, work, map[string]string{"package.json": pkg, "tsconfig.json": `{"compilerOptions":{"outDir":"dist"}}`, + "src/index.ts": "export const a = 1;\n", "test/retire.test.ts": "import '../dist/index.js';\n"}) + out, err := m.test(context.Background(), testArgs(level)) + if err != nil { + t.Fatal(err) + } + r := out.(*TestResult) + if !slices.Contains(r.Ran, "build") || slices.Contains(r.Ran, "type-check") { + t.Errorf("%s: ran %v, want the build and no separate type-check", level, r.Ran) + } + first := joined(f.calls[0]) + if strings.Contains(first, "--noEmit") || !strings.HasSuffix(first, "typescript/bin/tsc -p .") { + t.Errorf("%s: the build ran %s, want tsc -p . emitting", level, first) + } + if len(f.shut) == 0 || !strings.Contains(f.shut[0].command, "tsc -p .") { + t.Errorf("%s: the build did not run shut off", level) + } + } + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"package.json": `{"scripts":{"test":"node --test"}}`, "tsconfig.json": `{}`, "index.ts": "export const a = 1;\n"}) + if _, err := m.test(context.Background(), testArgs("unit")); err != nil { + t.Fatal(err) + } + if first := joined(f.calls[0]); !strings.Contains(first, "--noEmit") { + t.Errorf("a package that does not build ran %s, want the type-check", first) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go index 058a80d8..e0ca6bf3 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go @@ -59,6 +59,18 @@ type FetchResult struct { Conflicts []string `json:"conflicts,omitempty"` Changed []string `json:"changed,omitempty"` Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"` + // Group is the group's package the fetch packed and served to the TypeScript members (the call's `package`): + // "@ ". + Group string `json:"group,omitempty"` +} + +// narrowedMark is the file beside a workspace's tree saying the fetch narrowed it to a module's named paths. +const narrowedMark = "narrowed" + +// narrowed says whether the fetch narrowed the workspace whose tree this is. +func narrowed(tree string) bool { + _, err := os.Stat(filepath.Join(filepath.Dir(tree), narrowedMark)) + return err == nil } // Refusal is a module a fetch did not install the packages of. @@ -79,7 +91,12 @@ func (m *Module) fetchTool() StepTool { "repository": map[string]any{"type": "string", "description": "/ on the forge"}, "commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"}, "ref": map[string]any{"type": "string", "description": "refs/pull//head when only a pull request's head carries the commit"}, - "base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"}, + "dir": buildstep.FetchRequestProperties()["dir"], + "paths": buildstep.FetchRequestProperties()["paths"], + "package": map[string]any{"type": "object", "description": "optional: the group's package member (ADR 0500 §5), packed here, publishing nothing, and served to each TypeScript member whose lock names its version, in place of the registry's", + "properties": map[string]any{"repository": map[string]any{"type": "string"}, "commit": map[string]any{"type": "string"}, "ref": map[string]any{"type": "string"}}, + "required": []string{"repository", "commit"}}, + "base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"}, }, "required": []string{"repository", "commit"}, }, @@ -89,15 +106,16 @@ func (m *Module) fetchTool() StepTool { } func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) { - repository, _ := args["repository"].(string) - commit, _ := args["commit"].(string) - ref, _ := args["ref"].(string) - base, _ := args["base"].(string) - if base != "" && !commitOK.MatchString(base) { - return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base) + // What the builder and the checker ask, in mesh-sdk's one form (ADR 0499 §4): with the module's directory and its + // named paths, the workspace is narrowed to them (buildstep.Narrow). + req, err := buildstep.ParseFetchRequest(args) + if err != nil { + return nil, err } - if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") { - return nil, fmt.Errorf("%q is not a repository, /", repository) + repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base + group, err := groupPackageArg(args["package"]) + if err != nil { + return nil, err } m.CleanWorkspaces(m.now()) id := buildstep.NewID() @@ -123,16 +141,49 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) { return res, nil // a conflict: the tree is at the trunk, nothing of the head is installed } } - dirs, err := m.typeScriptModules(ctx, tree) + if len(req.Paths) > 0 { + if err := buildstep.Narrow(tree, req); err != nil { + return nil, err + } + // Said beside the tree, so the listing of the tree's modules leaves out those narrowed away. + if err := os.WriteFile(filepath.Join(root, narrowedMark), nil, 0o600); err != nil { + return nil, err + } + } + // The group's package, packed here and served to its members in place of the registry's version (ADR 0500 §5). + var served *packed + if group != nil { + p, err := m.packGroup(ctx, root, gitRun, group) + if err != nil { + return nil, err + } + served, res.Group = &p, p.Name+"@"+p.Version+" "+p.Integrity + } + dirs, packages, err := m.typeScriptModules(ctx, tree) if err != nil { return nil, err } for _, d := range dirs { res.Modules = append(res.Modules, d) - did, refused, err := m.installOwn(ctx, root, filepath.Join(tree, filepath.FromSlash(d)), d) + dir := filepath.Join(tree, filepath.FromSlash(d)) + if packages[d] { + // A package is built from its own lock's dependencies, its devDependencies included (ADR 0498 §5). + if err := m.installDev(ctx, root, dir, d); err != nil { + return nil, err + } + res.Installed = append(res.Installed, d) + continue + } + did, refused, err := m.installOwn(ctx, root, dir, d) if err != nil { return nil, err } + if refused == "" { + // The SDK the module's own lock names, from the group or the registry (the lead's decision L10). + if err := m.placeSDK(ctx, dir, d, served); err != nil { + return nil, err + } + } if refused != "" { res.Refused = append(res.Refused, Refusal{Module: d, Reason: refused}) } @@ -145,19 +196,22 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) { // typeScriptModules are the directories of the tree whose module.json declares a typescript artifact, relative to // it, in order. -func (m *Module) typeScriptModules(ctx context.Context, tree string) ([]string, error) { +func (m *Module) typeScriptModules(ctx context.Context, tree string) (dirs []string, packages map[string]bool, err error) { r := m.Run(ctx, m.baseEnv(), "git", "-C", tree, "ls-files", "--", "module.json", "*/module.json") if r.Status != 0 || r.Err != "" { - return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr)) + return nil, nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr)) } - var dirs []string + packages = map[string]bool{} for _, l := range strings.Split(r.Stdout, "\n") { if l = strings.TrimSpace(l); l == "" { continue } raw, err := os.ReadFile(filepath.Join(tree, filepath.FromSlash(l))) + if os.IsNotExist(err) && narrowed(tree) { + continue // outside a narrowed workspace's paths (buildstep.Narrow): no module of this build + } if err != nil { - return nil, err + return nil, nil, err } var mj struct { Build struct { @@ -169,13 +223,15 @@ func (m *Module) typeScriptModules(ctx context.Context, tree string) ([]string, } for _, a := range mj.Build.Artifacts { if a.Language == "typescript" { - dirs = append(dirs, filepath.ToSlash(filepath.Dir(l))) + d := filepath.ToSlash(filepath.Dir(l)) + dirs = append(dirs, d) + packages[d] = a.Kind == "package" break } } } sort.Strings(dirs) - return dirs, nil + return dirs, packages, nil } // ownDependencies are the package.json's dependencies beyond the SDK and beyond what is build-time: the types and @@ -229,13 +285,6 @@ func (m *Module) installOwn(ctx context.Context, root, dir, rel string) (install if err != nil { return false, "", err } - if m.Toolchain == nil || m.Toolchain.Registry == nil { - return false, "", fmt.Errorf("the module has no package registry to install %s from", rel) - } - reg, err := m.Toolchain.Registry() - if err != nil { - return false, "", err - } // A scratch copy, so the module's package.json and lock are never rewritten (dependencies.go installSteps). scratch := filepath.Join(root, "npm-"+strings.NewReplacer("/", "-", ".", "root").Replace(rel)) if err := os.MkdirAll(scratch, 0o700); err != nil { @@ -255,10 +304,11 @@ func (m *Module) installOwn(ctx context.Context, root, dir, rel string) (install if err := os.WriteFile(filepath.Join(scratch, "package-lock.json"), lock, 0o600); err != nil { return false, "", err } - // The registry is given through a config file of its own, never on the command line, and removed after. - npmrc := filepath.Join(scratch, ".npmrc-install") - if err := os.WriteFile(npmrc, []byte(reg.Npmrc()), 0o600); err != nil { - return false, "", err + // The registry is given through a config file of its own, never on the command line, outside the workspace, and + // removed after (npmrcIn). + npmrc, err := m.npmrcIn(root) + if err != nil { + return false, "", fmt.Errorf("installing the packages of %s: %w", rel, err) } env := append(m.baseEnv(), "npm_config_cache="+filepath.Join(m.Toolchain.Paths.Cache, "npm")) r := m.Run(ctx, env, "npm", "ci", "--omit=dev", "--omit=peer", "--ignore-scripts", "--no-bin-links", diff --git a/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go b/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go index da417017..abd3a634 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go @@ -27,7 +27,9 @@ type call struct { type fakeMachine struct { mu sync.Mutex calls []call - now time.Time + // shut are the commands that ran in bubblewrap, read back (isolate_test.go). + shut []shutOff + now time.Time // cost is how long a program takes, by the base name of its path; answer is what it prints and its status; // effect runs the program's effects on the disk. cost map[string]time.Duration @@ -74,7 +76,10 @@ func newMachine(t *testing.T) (*Module, *fakeMachine, string) { Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}, Registry: func() (Registry, error) { return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil }}, - Work: work, Run: f.run, Clock: f.clock, Ctx: context.Background()} + Work: work, Clock: f.clock, Ctx: context.Background()} + // A run step's commands run in bubblewrap (1309); the fake reads each back and runs its command. + m.Run = throughBubblewrap(t, f.run, &f.shut) + withStore(t, m) // a build pushes its bundle to a store of the test's own (ADR 0498) // The toolchain directory as npm ci leaves it, with no bin links. nm := filepath.Join(m.toolchainDir(), "node_modules") for _, p := range []string{"typescript/bin/tsc", "esbuild/bin/esbuild", "@esbuild/linux-x64/bin/esbuild", diff --git a/modules/mesh-build-node/cmd/mesh-build-node/isolate.go b/modules/mesh-build-node/cmd/mesh-build-node/isolate.go new file mode 100644 index 00000000..b6222da9 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/isolate.go @@ -0,0 +1,110 @@ +package main + +// The run steps shut off (novox/hq ADR 0448; issue 1309, lead decision L2 of R4e): the type-check, the package's test +// script, tsc and esbuild run the change's code, so each runs through mesh-sdk's buildstep.Isolated, in bubblewrap: no +// network, none of the account (its home, its ssh key, its registry secret, its bus login, the runtime directory), the +// workspace read-write, the toolchain read-only, the shared npm cache read-only beneath a throwaway writable layer, or +// read-write for a trunk build alone (publish). The fetch step (git, npm ci of the module's own packages) holds the key +// and the network and runs none of the change's code, so it runs as before. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// BubblewrapLeast is the oldest bubblewrap a run step is shut off with: --overlay-src and --tmp-overlay, which keep the +// shared cache unchanged by a candidate, came in 0.10. +const BubblewrapLeast = "0.10" + +// npmCache is the shared npm cache a package's test script reads. +func (m *Module) npmCache() string { return filepath.Join(m.Toolchain.Paths.Cache, "npm") } + +// isolation is how a run step in the workspace root is shut off. +func (m *Module) isolation(root string, write bool) buildstep.Isolation { + return buildstep.Isolation{ + Workspace: root, + ReadOnly: []string{m.toolchainDir()}, + Caches: []string{m.npmCache()}, + WriteCaches: write, + Env: []string{"PATH=" + pathOrDefault(), "LC_ALL=C"}, + } +} + +// stepRunner is the runner of a run step in the workspace root: buildstep.Isolated over m.Run, every command shut off; +// write is a trunk build's alone. +func (m *Module) stepRunner(root string, write bool) (forgekey.Runner, error) { + // bubblewrap binds only a directory that is there. + if err := os.MkdirAll(m.npmCache(), 0o700); err != nil { + return nil, fmt.Errorf("the shared cache %s: %w", m.npmCache(), err) + } + return buildstep.Isolated(m.Run, m.isolation(root, write)), nil +} + +// ProbeBubblewrap reads the installed bubblewrap's version (`bwrap --version`: "bubblewrap 0.13.0"). +func ProbeBubblewrap(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + r := forgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") + if r.Status != 0 || r.Err != "" { + return "", fmt.Errorf("`%s --version` failed: %s", buildstep.Bubblewrap, firstLine(r.Err+"\n"+r.Stderr)) + } + return parseBubblewrapVersion(r.Stdout) +} + +var bubblewrapVersion = regexp.MustCompile(`^bubblewrap (\d+)\.(\d+)(\.\d+)?\s*$`) + +func parseBubblewrapVersion(out string) (string, error) { + f := bubblewrapVersion.FindStringSubmatch(strings.TrimSpace(out)) + if f == nil { + return "", fmt.Errorf("`%s --version` answered %q", buildstep.Bubblewrap, strings.TrimSpace(out)) + } + return f[1] + "." + f[2] + f[3], nil +} + +// bubblewrapProblem says why the installed bubblewrap cannot shut a run step off, or "". +func bubblewrapProblem(version string, err error) string { + if err != nil { + return "bubblewrap cannot be run, and every build and test step runs in it (ADR 0448): " + err.Error() + } + if !atLeast(version, BubblewrapLeast) { + return fmt.Sprintf("bubblewrap %s installed, and a build or test step needs %s or later (ADR 0448)", version, BubblewrapLeast) + } + return "" +} + +// atLeast says whether the dotted version v is least or later. +func atLeast(v, least string) bool { + a, b := strings.Split(v, "."), strings.Split(least, ".") + for i := range b { + var x int + if i < len(a) { + x, _ = strconv.Atoi(a[i]) + } + y, _ := strconv.Atoi(b[i]) + if x != y { + return x > y + } + } + return true +} + +// checkBubblewrap refuses a step when bubblewrap cannot shut it off; nil Bubblewrap probes nothing. +func (m *Module) checkBubblewrap(ctx context.Context) error { + if m.Bubblewrap == nil { + return nil + } + v, err := m.Bubblewrap(ctx) + if why := bubblewrapProblem(v, err); why != "" { + return &buildstep.Refusal{Text: why, Want: BubblewrapLeast + " or later", Have: v} + } + return nil +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/isolate_test.go b/modules/mesh-build-node/cmd/mesh-build-node/isolate_test.go new file mode 100644 index 00000000..7f3f7391 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/isolate_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "slices" + "strings" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// shutOff is one command a step ran in bubblewrap: what it bound read-write, read-only and beneath a throwaway layer, +// its bubblewrap flags, and the command. +type shutOff struct { + flags []string + binds, roBinds, overlays []string + env []string + command string +} + +// bwrapArity is how many words each bubblewrap option the SDK writes takes. +var bwrapArity = map[string]int{"--unshare-all": 0, "--die-with-parent": 0, "--new-session": 0, "--clearenv": 0, + "--proc": 1, "--dev": 1, "--tmpfs": 1, "--dir": 1, "--chdir": 1, "--overlay-src": 1, "--tmp-overlay": 1, + "--ro-bind": 2, "--ro-bind-try": 2, "--bind": 2, "--symlink": 2, "--setenv": 2} + +// throughBubblewrap reads a bubblewrap command back (recorded in seen) and hands its command to run with the environment +// bubblewrap sets (a name set twice keeps its last value, as bubblewrap's --setenv does); any other command goes to run +// as it is. +func throughBubblewrap(t *testing.T, run forgekey.Runner, seen *[]shutOff) forgekey.Runner { + t.Helper() + var mu sync.Mutex + return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + if name != buildstep.Bubblewrap { + return run(ctx, env, name, args...) + } + var s shutOff + var names []string + values := map[string]string{} + i := 0 + for ; i < len(args) && args[i] != "--"; i++ { + n, ok := bwrapArity[args[i]] + if !ok || i+n >= len(args) { + t.Errorf("bubblewrap was given %q, which the test does not know: %v", args[i], args) + return forgekey.Ran{Status: 1, Err: "unknown bubblewrap option"} + } + s.flags = append(s.flags, args[i]) + switch args[i] { + case "--bind": + s.binds = append(s.binds, args[i+1]) + case "--ro-bind", "--ro-bind-try": + s.roBinds = append(s.roBinds, args[i+1]) + case "--overlay-src": + s.overlays = append(s.overlays, args[i+1]) + case "--setenv": + if _, set := values[args[i+1]]; !set { + names = append(names, args[i+1]) + } + values[args[i+1]] = args[i+2] + } + i += n + } + if i+1 >= len(args) { + t.Errorf("bubblewrap was given no command: %v", args) + return forgekey.Ran{Status: 1, Err: "no command"} + } + for _, n := range names { + s.env = append(s.env, n+"="+values[n]) + } + s.command = args[i+1] + " " + strings.Join(args[i+2:], " ") + mu.Lock() + *seen = append(*seen, s) + mu.Unlock() + return run(ctx, s.env, args[i+1], args[i+2:]...) + } +} + +// checkShutOff fails the test unless s ran with no network and none of the account, the toolchain read-only, and the +// shared cache beneath a throwaway layer unless write. +func checkShutOff(t *testing.T, m *Module, root string, s shutOff, write bool) { + t.Helper() + for _, f := range []string{"--unshare-all", "--clearenv", "--die-with-parent"} { + if !slices.Contains(s.flags, f) { + t.Errorf("%s ran without %s", s.command, f) + } + } + home := m.Toolchain.Paths.Home + for _, b := range append(append([]string{}, s.binds...), s.roBinds...) { + if b == home || strings.HasPrefix(b, home+"/") || strings.HasPrefix(b, "/run") { + t.Errorf("%s was given %s: the account's home or the runtime directory", s.command, b) + } + } + for _, e := range s.env { + if e == "HOME="+home { + t.Errorf("%s was told the account's home", s.command) + } + } + if !slices.Contains(s.roBinds, m.toolchainDir()) || slices.Contains(s.binds, m.toolchainDir()) { + t.Errorf("%s was not given the toolchain read-only: %v %v", s.command, s.roBinds, s.binds) + } + c := m.npmCache() + bound, layered := slices.Contains(s.binds, c), slices.Contains(s.overlays, c) + if write != bound || write == layered { + t.Errorf("%s: the shared cache bound %v, layered %v; a trunk build (%v) alone writes it", s.command, bound, layered, write) + } + if !slices.Contains(s.binds, root) { + t.Errorf("%s was not given its workspace %s read-write: %v", s.command, root, s.binds) + } +} + +// 1309-S7: the type-check and the test script run shut off, the cache beneath a throwaway layer. +func TestTheTestStepRunsItsCommandsShutOff(t *testing.T) { + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"index.ts": `export const a = 1;`, "package.json": `{"scripts":{"test":"x"}}`}) + if _, err := m.test(context.Background(), testArgs("unit")); err != nil { + t.Fatal(err) + } + if len(f.shut) != 2 || len(f.calls) != 2 { + t.Fatalf("shut off %d of %d commands, want the type-check and the script: %v", len(f.shut), len(f.calls), f.calls) + } + for _, s := range f.shut { + checkShutOff(t, m, work+"/w1", s, false) + } +} + +// 1309-S8: tsc and esbuild of a candidate's build run shut off with the cache beneath a throwaway layer; a trunk build +// (publish) writes it. +func TestTheBuildStepRunsShutOffAndOnlyATrunkBuildWritesTheCache(t *testing.T) { + for _, publish := range []bool{false, true} { + m, f, work := builtMachine(t, "") + ws := "w1" + args := buildArgs() + if publish { + args["publish"] = true + } + if _, err := m.build(context.Background(), args); err != nil { + t.Fatalf("publish %v: %v", publish, err) + } + if len(f.shut) == 0 || len(f.shut) != len(f.calls) { + t.Fatalf("publish %v: shut off %d of %d commands", publish, len(f.shut), len(f.calls)) + } + for _, s := range f.shut { + checkShutOff(t, m, work+"/"+ws, s, publish) + } + } +} + +// 1309-S1: a step is refused, and the module is unhealthy, while bubblewrap is missing or older than 0.10. +func TestAnOldOrMissingBubblewrapRefusesTheStepsAndIsUnhealthy(t *testing.T) { + for _, c := range []struct { + version string + err error + ok bool + }{{"0.13.0", nil, true}, {"0.10", nil, true}, {"0.9.0", nil, false}, {"", errors.New("bwrap: not found"), false}} { + m, _, _ := newMachine(t) + m.Probe = func(context.Context) (string, error) { return "v22.20.0", nil } + m.Toolchains = func(string, any) (uint64, error) { return 1, nil } + m.Bubblewrap = func(context.Context) (string, error) { return c.version, c.err } + err := m.Check(context.Background()) + raw, _ := json.Marshal(m.Health(context.Background())) + if c.ok { + if err != nil || strings.Contains(string(raw), "unhealthy") { + t.Errorf("bubblewrap %q: refused %v, health %s", c.version, err, raw) + } + continue + } + var ref *buildstep.Refusal + if !errors.As(err, &ref) || !strings.Contains(err.Error(), "bubblewrap") { + t.Errorf("bubblewrap %q %v: Check answered %v", c.version, c.err, err) + } + if !strings.Contains(string(raw), "unhealthy") || !strings.Contains(string(raw), "bubblewrap") { + t.Errorf("bubblewrap %q %v: health %s", c.version, c.err, raw) + } + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/isolation_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/isolation_integration_test.go new file mode 100644 index 00000000..095f16cf --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/isolation_integration_test.go @@ -0,0 +1,91 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" +) + +// probeScript is a package's test script that tries what a change's code must not reach and writes what it saw. +const probeScript = `import fs from "node:fs"; +import dns from "node:dns/promises"; +import net from "node:net"; +const [key, login, port, sock, cache] = process.argv.slice(2); +const seen = {}; +const read = (p) => { try { fs.readFileSync(p); return true; } catch { return false; } }; +seen.key = read(key); +seen.login = read(login); +try { await dns.lookup("git.novox.be"); seen.resolved = true; } catch { seen.resolved = false; } +const reach = (opts) => new Promise((ok) => { const c = net.connect(opts); c.on("connect", () => { c.destroy(); ok(true); }); c.on("error", () => ok(false)); }); +seen.tcp = await reach({ host: "127.0.0.1", port: Number(port) }); +seen.socket = await reach({ path: sock }); +try { fs.writeFileSync(cache + "/written-by-a-candidate", "x"); seen.wroteCache = true; } catch { seen.wroteCache = false; } +fs.writeFileSync("seen.json", JSON.stringify(seen)); +` + +// 1309-S9 (ADR 0448's check): a package's test script, run by the test step, reads no key and no login of the account, +// resolves no name, reaches no socket of the machine (a TCP port on its loopback, a unix socket), and leaves the +// shared cache unchanged. +func TestARunStepReachesNoKeyLoginNameOrSocketAndLeavesTheCacheUnchanged(t *testing.T) { + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("NOT RUN: no bubblewrap installed where this check runs; every build node has it (the module declares it)") + } + m, work := realMachine(t) + home := m.Toolchain.Paths.Home + key, login := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, "broker") + write(t, key, "a key", 0o600) + write(t, login, "a login", 0o600) + tcp, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer tcp.Close() + sockDir, err := os.MkdirTemp("", "sock") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(sockDir) + sock := filepath.Join(sockDir, "s") + unix, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + defer unix.Close() + if err := os.MkdirAll(m.npmCache(), 0o700); err != nil { + t.Fatal(err) + } + port := strconv.Itoa(tcp.Addr().(*net.TCPAddr).Port) + dir := filepath.Join(work, "w1", "tree", "modules", "m") + write(t, filepath.Join(dir, "probe.mjs"), probeScript, 0o644) + write(t, filepath.Join(dir, "package.json"), `{"name":"m","type":"module","scripts":{"test":"node probe.mjs `+key+` `+login+` `+port+` `+sock+` `+m.npmCache()+`"}}`, 0o644) + out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/m", "level": "unit"}) + if err != nil { + t.Fatal(err) + } + if r := out.(*TestResult); !r.Passed { + t.Fatalf("the probe did not run: %+v", r) + } + raw, err := os.ReadFile(filepath.Join(dir, "seen.json")) + if err != nil { + t.Fatalf("the probe wrote nothing: %v", err) + } + var seen map[string]bool + if err := json.Unmarshal(raw, &seen); err != nil { + t.Fatal(err) + } + for what, reached := range seen { + if reached && what != "wroteCache" { + t.Errorf("the run step reached %s: %s", what, raw) + } + } + if _, err := os.Stat(filepath.Join(m.npmCache(), "written-by-a-candidate")); err == nil { + t.Errorf("a candidate's step changed the shared cache") + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index 841d4bbd..f06354a2 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -51,12 +51,19 @@ func main() { Run: forgekey.ExecRunnerClean(0), Ctx: ctx, Say: logf, + Bubblewrap: ProbeBubblewrap, + RegistryFile: filepath.Join(home, "registry"), } // Outside a launch by the runtime — a build, a check — there is no node and no state to write. if m.Node != "" { go m.Start(ctx) } go startKeeper(ctx) + // Every state it keeps, said on the bus before it serves (ADR 0495 §2; issue 1790). + if err := keepStates(); err != nil { + logf("announcing the states it keeps: %v", err) + os.Exit(1) + } if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil { logf("%v", err) os.Exit(1) diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index efc359e9..74c56cff 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -66,7 +66,7 @@ func TestManifestIsValidAndDeclaresTheAccountTheCapabilityAndThePackages(t *test } } slices.Sort(packages) - if want := []string{"git", "openssh"}; !slices.Equal(packages, want) { + if want := []string{"bubblewrap", "git", "openssh"}; !slices.Equal(packages, want) { t.Errorf("packages = %v, want %v", packages, want) } } @@ -194,3 +194,47 @@ func TestTheToolchainFilesAreAnArchiveUnpackedIntoTheHome(t *testing.T) { t.Errorf("the module carries no toolchain/package-lock.json: %v", err) } } + +// 1309-S1: every run step runs in bubblewrap, so the module declares its package (the lead's decision: each build module +// that runs a step through it declares it), beside the user-namespaces capability bubblewrap needs. +func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) { + m := readManifest(t) + if r := resource(t, m, "bubblewrap"); r["type"] != "package" || r["package"] != "bubblewrap" { + t.Errorf("the bubblewrap resource is %v, want the package bubblewrap", r) + } + if !slices.Contains(m.Capabilities, "user-namespaces") { + t.Errorf("capabilities %v lack user-namespaces", m.Capabilities) + } +} + +// 1587-S5 (ADR 0498 §2): the module requires the artifact store and is told its address in a file of its home, which no +// run step is given (a run step has no network either, ADR 0448). +func TestTheModuleRequiresTheArtifactStoreAndIsToldItsAddress(t *testing.T) { + m := readManifest(t) + if !slices.Contains(m.Requires, "artifact-store") { + t.Errorf("requires %v lack artifact-store", m.Requires) + } + // The validator reads the template (files/registry) into the resource's content. + r := resource(t, m, "registry") + if content, _ := r["content"].(string); r["type"] != "file" || r["path"] != "${dir:state}/registry" || !strings.Contains(content, "${seat:mesh-artifact-store:reach}") { + t.Errorf("the registry resource is %v", r) + } +} + +// 1587-S17 (ADR 0498 §5): the module publishes a trunk package itself, so its contribution to the package registry asks +// for write access; the credential stays with the module's own process (a run step has neither the home nor a network). +func TestTheModuleContributesWriteAccessToThePackageRegistry(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var mj struct { + Contributes map[string]map[string]any `json:"contributes"` + } + if err := json.Unmarshal(raw, &mj); err != nil { + t.Fatal(err) + } + if got := mj.Contributes["npm-package-registry"]["access"]; got != "write" { + t.Errorf("npm-package-registry access = %v, want write", got) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index 1f9e60d0..94c4f87a 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "net/http" "os" "sync" "time" @@ -48,6 +49,15 @@ type Module struct { // Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not). Ctx context.Context Say func(format string, a ...any) + // HTTP reads and publishes to the package registry; a client of its own when nil. A test serves its own. + HTTP *http.Client + // RegistryFile holds the artifact store's address (host:port), placed by the manifest; Push puts a blob there + // (buildstep.PushBlob when nil; a test's fake store). + RegistryFile string + Push func(ctx context.Context, registry, repository, digest string, body []byte) error + // Bubblewrap reads the installed bubblewrap's version (ProbeBubblewrap): a step is refused, and the module is + // unhealthy, while it is missing or older than BubblewrapLeast. Nil probes nothing. + Bubblewrap func(ctx context.Context) (string, error) mu sync.Mutex // guards Recipe install sync.Mutex // one install at a time @@ -87,7 +97,10 @@ func (m *Module) check(ctx context.Context) (string, error) { m.mu.Unlock() } installed, err := m.Probe(ctx) - return installed, m.guard(m.recipe(), installed, err).Check(ctx) + if err := m.guard(m.recipe(), installed, err).Check(ctx); err != nil { + return installed, err + } + return installed, m.checkBubblewrap(ctx) } // Prepare is Check and then the toolchain directory of the recipe in force: what every step runs behind. diff --git a/modules/mesh-build-node/cmd/mesh-build-node/package.go b/modules/mesh-build-node/cmd/mesh-build-node/package.go new file mode 100644 index 00000000..0dc62939 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/package.go @@ -0,0 +1,527 @@ +package main + +// A TypeScript package (novox/hq ADR 0498 §5, ADR 0500 §4-§6; issue 1587, the lead's decision L10): built from its own +// lock's devDependencies (the toolchain's SDK is never used to build the SDK itself), packed with npm pack, and, for a +// group, served to its TypeScript members' fetch in place of the registry's version. A member is built against the SDK +// version its own lock names: the fetch places that version in the member's node_modules, from the group's packed +// package when the group carries that version, else from the registry; a version neither holds waits for its +// publication (`waits for:`). The install of a package's devDependencies is the fetch's (it holds the network and runs +// no script); its build and pack run shut off like every run step. + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// maxPackage is the largest packed package read: far above the SDK's. +const maxPackage = 64 << 20 + +// packed is a package built and packed: its name and version from its package.json, the packed file's bytes, its npm +// integrity (sha512, as a lock names it) and its digest (sha256, as a done key names it). +type packed struct { + Name, Version, Integrity, Digest string + tgz []byte +} + +// integrityOf is a packed file's integrity as npm writes it in a lock: sha512- and the base64 of its sha512. +func integrityOf(b []byte) string { + sum := sha512.Sum512(b) + return "sha512-" + base64.StdEncoding.EncodeToString(sum[:]) +} + +func digestOf(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// packageJSON is the part of a package.json the package steps read. +type packageJSON struct { + Name string `json:"name"` + Version string `json:"version"` + Scripts map[string]string `json:"scripts"` +} + +func readPackageJSON(dir string) (packageJSON, error) { + var p packageJSON + raw, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return p, fmt.Errorf("the package has no package.json: %w", err) + } + if err := json.Unmarshal(raw, &p); err != nil { + return p, fmt.Errorf("the package.json is not JSON: %w", err) + } + if p.Name == "" || p.Version == "" { + return p, fmt.Errorf("the package.json names no name or no version") + } + return p, nil +} + +// installDev installs a package's own dependencies, its devDependencies included, from its lock (the fetch: network, +// no script, no bin link), and writes a shim for each program they carry into /bin-, files and not links, +// so its build script finds them by name. +func (m *Module) installDev(ctx context.Context, root, dir, rel string) error { + if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil { + return fmt.Errorf("the package in %s has no package-lock.json: commit one", rel) + } + npmrc, err := m.npmrcIn(root) + if err != nil { + return err + } + defer os.Remove(npmrc) + env := append(m.baseEnv(), "npm_config_cache="+m.npmCache()) + r := m.Run(ctx, env, "npm", "ci", "--ignore-scripts", "--no-bin-links", "--no-audit", "--no-fund", "--prefix", dir, "--userconfig", npmrc) + if why := ranFailed(r); why != "" { + return fmt.Errorf("installing the dependencies of the package in %s: %s", rel, why) + } + return writeShims(filepath.Join(dir, "node_modules"), shimDir(root, rel)) +} + +// npmrcIn writes the npm configuration for the mesh's registry, with its credential, for one command of this process; +// the caller removes it. It lies in this process's temporary directory, never in a workspace: a run step is given the +// workspace and a /tmp of its own, so no step's mounts hold the credential (ADR 0498 §5, ADR 0500 §4). +func (m *Module) npmrcIn(root string) (string, error) { + if m.Toolchain == nil || m.Toolchain.Registry == nil { + return "", fmt.Errorf("the module has no package registry") + } + reg, err := m.Toolchain.Registry() + if err != nil { + return "", err + } + f, err := os.CreateTemp("", ".npmrc-") + if err != nil { + return "", err + } + defer f.Close() + if _, err := f.WriteString(reg.Npmrc()); err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil +} + +// shimDir is where the shims of the package in rel are written. +func shimDir(root, rel string) string { + return filepath.Join(root, "bin-"+strings.NewReplacer("/", "-", ".", "root").Replace(rel)) +} + +// writeShims writes, for every program a top-level package of nm names in its package.json `bin`, a shell script that +// runs it by its real path. +func writeShims(nm, into string) error { + if err := os.MkdirAll(into, 0o755); err != nil { + return err + } + var dirs []string + entries, _ := os.ReadDir(nm) + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + if strings.HasPrefix(e.Name(), "@") { + scoped, _ := os.ReadDir(filepath.Join(nm, e.Name())) + for _, s := range scoped { + if s.IsDir() { + dirs = append(dirs, filepath.Join(nm, e.Name(), s.Name())) + } + } + continue + } + dirs = append(dirs, filepath.Join(nm, e.Name())) + } + sort.Strings(dirs) + for _, d := range dirs { + raw, err := os.ReadFile(filepath.Join(d, "package.json")) + if err != nil { + continue + } + var p struct { + Name string `json:"name"` + Bin json.RawMessage `json:"bin"` + } + if json.Unmarshal(raw, &p) != nil || len(p.Bin) == 0 { + continue + } + bins := map[string]string{} + var one string + if json.Unmarshal(p.Bin, &one) == nil { + bins[path.Base(p.Name)] = one + } else if json.Unmarshal(p.Bin, &bins) != nil { + continue + } + for name, target := range bins { + if name == "" || strings.ContainsAny(name, "/\\") { + continue + } + body := "#!/bin/sh\nexec node '" + filepath.Join(d, filepath.FromSlash(target)) + "' \"$@\"\n" + p := filepath.Join(into, name) + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { + return err + } + if err := os.Chmod(p, 0o755); err != nil { + return err + } + } + } + return nil +} + +// buildAndPack builds the package in dir with its own build script, if it has one, and packs it with npm pack, both +// shut off (write: a trunk build's, which alone writes the shared cache). The packed file is read and removed: it never +// stays on the node. +func (m *Module) buildAndPack(ctx context.Context, root, dir, rel string, write bool) (packed, error) { + p, err := readPackageJSON(dir) + if err != nil { + return packed{}, err + } + env := []string{"PATH=" + shimDir(root, rel) + ":" + pathOrDefault(), "npm_config_cache=" + m.npmCache()} + if p.Scripts["build"] != "" { + if why := ranFailed(m.runIn(ctx, root, write, dir, env, "npm", "run", "build")); why != "" { + return packed{}, fmt.Errorf("the build of the package %s: %s", p.Name, why) + } + } + out, err := os.MkdirTemp(root, "packed-") + if err != nil { + return packed{}, err + } + defer os.RemoveAll(out) + if why := ranFailed(m.runIn(ctx, root, write, dir, env, "npm", "pack", "--ignore-scripts", "--pack-destination", out)); why != "" { + return packed{}, fmt.Errorf("packing the package %s: %s", p.Name, why) + } + files, _ := filepath.Glob(filepath.Join(out, "*.tgz")) + if len(files) != 1 { + return packed{}, fmt.Errorf("npm pack of %s left %d packed files, want one", p.Name, len(files)) + } + tgz, err := os.ReadFile(files[0]) + if err != nil { + return packed{}, err + } + return packed{Name: p.Name, Version: p.Version, Integrity: integrityOf(tgz), Digest: digestOf(tgz), tgz: tgz}, nil +} + +// groupPackage is the fetch's `package` argument: the group's package member (ADR 0500 §5), which the fetch packs and +// serves to its TypeScript members in place of the registry's version. +type groupPackage struct{ Repository, Commit, Ref string } + +func groupPackageArg(v any) (*groupPackage, error) { + if v == nil { + return nil, nil + } + o, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("package is {repository, commit, ref}") + } + g := &groupPackage{} + g.Repository, _ = o["repository"].(string) + g.Commit, _ = o["commit"].(string) + g.Ref, _ = o["ref"].(string) + if !repositoryOK.MatchString(g.Repository) || strings.Contains(g.Repository, "..") { + return nil, fmt.Errorf("package: %q is not a repository, /", g.Repository) + } + if !commitOK.MatchString(g.Commit) { + return nil, fmt.Errorf("package: %q is not a full commit (40 hex digits)", g.Commit) + } + if g.Ref != "" && !strings.HasPrefix(g.Ref, "refs/pull/") { + return nil, fmt.Errorf("package: %q is not a pull request's head", g.Ref) + } + return g, nil +} + +// packGroup fetches the group's package member into the workspace, installs its dependencies, builds and packs it, +// publishing nothing (ADR 0500 §4). +func (m *Module) packGroup(ctx context.Context, root string, run forgekey.Runner, g *groupPackage) (packed, error) { + dir := filepath.Join(root, "package", "tree") + if err := os.MkdirAll(dir, 0o700); err != nil { + return packed{}, err + } + if err := fetchCommit(ctx, run, dir, m.remote(g.Repository), g.Commit, g.Ref, m); err != nil { + return packed{}, fmt.Errorf("the group's package %s: %w", g.Repository, err) + } + if err := m.installDev(ctx, root, dir, "package"); err != nil { + return packed{}, err + } + return m.buildAndPack(ctx, root, dir, "package", false) +} + +// sdkLocked is what a module's lock names of the SDK: its version, its integrity and where it was resolved from; ok is +// false when the lock names none (the toolchain's SDK is then the module's, as before). +func sdkLocked(dir string) (version, integrity string, ok bool, err error) { + raw, err := os.ReadFile(filepath.Join(dir, "package-lock.json")) + if os.IsNotExist(err) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + var lock struct { + Packages map[string]struct { + Version string `json:"version"` + Integrity string `json:"integrity"` + } `json:"packages"` + } + if err := json.Unmarshal(raw, &lock); err != nil { + return "", "", false, fmt.Errorf("the package-lock.json is not JSON: %w", err) + } + p, ok := lock.Packages["node_modules/"+sdkPackage] + if !ok || p.Version == "" { + return "", "", false, nil + } + return p.Version, p.Integrity, true, nil +} + +// placeSDK places in the module's node_modules the SDK version its lock names (the lead's decision L10): the group's +// packed package when it is that version (its integrity must be the lock's), else the registry's; a version neither +// holds is a wait (`waits for:`) when no group package was given, and a failure naming both when one was. +func (m *Module) placeSDK(ctx context.Context, dir, rel string, group *packed) error { + version, integrity, ok, err := sdkLocked(dir) + if err != nil || !ok { + return err + } + want := sdkPackage + "@" + version + var tgz []byte + switch { + case group != nil && group.Name == sdkPackage && group.Version == version: + if integrity != "" && integrity != group.Integrity { + return fmt.Errorf("the lock of %s names %s with the integrity %s, and the group's package packs to %s", rel, want, integrity, group.Integrity) + } + tgz = group.tgz + default: + got, held, err := m.registryPackage(ctx, sdkPackage, version) + if err != nil { + return err + } + if !held { + if group != nil { + return fmt.Errorf("the lock of %s names %s, which neither the registry nor the group (%s@%s) holds", rel, want, group.Name, group.Version) + } + return fmt.Errorf("%s%s", delivery.WaitsFor, want) + } + if integrity != "" && integrityOf(got) != integrity { + return fmt.Errorf("the registry's %s is %s, and the lock of %s names %s", want, integrityOf(got), rel, integrity) + } + tgz = got + } + into := filepath.Join(dir, "node_modules", filepath.FromSlash(sdkPackage)) + if err := os.RemoveAll(into); err != nil { + return err + } + return unpackPackage(tgz, into) +} + +// httpClient is the client of the registry's reads and publishes: HTTP when a test set it. +func (m *Module) httpClient() *http.Client { + if m.HTTP != nil { + return m.HTTP + } + return &http.Client{Timeout: 5 * time.Minute} +} + +// packument is the part of the registry's document of a package the steps read: each version's packed file. +type packument struct { + Versions map[string]struct { + Dist struct { + Tarball string `json:"tarball"` + Integrity string `json:"integrity"` + } `json:"dist"` + } `json:"versions"` +} + +// registryVersion reads what the registry holds of name@version: its packed file's address and integrity, and whether +// it holds it at all. +func (m *Module) registryVersion(ctx context.Context, name, version string) (tarball, integrity string, held bool, err error) { + reg, err := m.Toolchain.Registry() + if err != nil { + return "", "", false, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reg.URL+url.PathEscape(name), nil) + if err != nil { + return "", "", false, err + } + reg.authorize(req) + resp, err := m.httpClient().Do(req) + if err != nil { + return "", "", false, fmt.Errorf("asking the registry for %s: %w", name, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return "", "", false, nil + } + if resp.StatusCode != http.StatusOK { + said, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return "", "", false, fmt.Errorf("the registry answered %s for %s: %s", resp.Status, name, strings.TrimSpace(string(said))) + } + var p packument + if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<20)).Decode(&p); err != nil { + return "", "", false, fmt.Errorf("the registry's document of %s: %w", name, err) + } + v, ok := p.Versions[version] + if !ok { + return "", "", false, nil + } + return v.Dist.Tarball, v.Dist.Integrity, true, nil +} + +// registryPackage downloads name@version's packed file from the registry; held is false when the registry has none. +func (m *Module) registryPackage(ctx context.Context, name, version string) ([]byte, bool, error) { + tarball, _, held, err := m.registryVersion(ctx, name, version) + if err != nil || !held { + return nil, held, err + } + reg, err := m.Toolchain.Registry() + if err != nil { + return nil, false, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tarball, nil) + if err != nil { + return nil, false, err + } + reg.authorize(req) + resp, err := m.httpClient().Do(req) + if err != nil { + return nil, false, fmt.Errorf("downloading %s@%s: %w", name, version, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("the registry answered %s for %s@%s's packed file", resp.Status, name, version) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxPackage+1)) + if err != nil { + return nil, false, err + } + if len(body) > maxPackage { + return nil, false, fmt.Errorf("%s@%s is larger than %d bytes", name, version, maxPackage) + } + return body, true, nil +} + +// unpackPackage unpacks a packed npm package (a gzipped tar whose entries lie under package/) into dir: files and +// directories only, nothing outside. +func unpackPackage(tgz []byte, dir string) error { + zr, err := gzip.NewReader(bytes.NewReader(tgz)) + if err != nil { + return fmt.Errorf("the packed package is not gzip: %w", err) + } + tr := tar.NewReader(zr) + for { + h, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + name := path.Clean(strings.TrimPrefix(h.Name, "./")) + rel, ok := strings.CutPrefix(name, "package/") + if !ok || rel == "" || path.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, "../") { + if name == "package" { + continue + } + return fmt.Errorf("the packed package holds %s, outside package/", h.Name) + } + target := filepath.Join(dir, filepath.FromSlash(rel)) + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + body, err := io.ReadAll(io.LimitReader(tr, maxPackage)) + if err != nil { + return err + } + if err := os.WriteFile(target, body, os.FileMode(h.Mode).Perm()|0o600); err != nil { + return err + } + default: + return fmt.Errorf("the packed package holds %s, neither a file nor a directory", h.Name) + } + } +} + +// fetchCommit fetches one commit into dir as the fetch step fetches the module's own (buildstep.FetchCommit). +func fetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, ref string, m *Module) error { + return buildstep.FetchCommit(ctx, run, dir, remote, commit, ref, m.FetchOptions...) +} + +// buildPackage builds and packs a package artifact (ADR 0498 §5): its digest is the packed file's. Only a trunk build +// (publish) publishes it, from this module's own process with the registry's write credential, which no run step is +// given: a version the registry lacks is published; the same packed file already there is done without a publish; +// another packed file under that version fails `version taken:`. The reference is @, answered only when +// the registry holds this build's file; a candidate answers its digest alone. +func (m *Module) buildPackage(ctx context.Context, workspace, module, root, dir string, a artifact, publish bool) (any, error) { + rel := module + if rel == "" { + rel = "." + } + p, err := m.buildAndPack(ctx, root, dir, rel, publish) + if err != nil { + return nil, err + } + res := BuildResult{Workspace: workspace, Module: module, Artifact: a.Name, Digest: p.Digest, Bytes: len(p.tgz)} + if publish { + if res.Reference, err = m.publish(ctx, root, p); err != nil { + return nil, err + } + } + return res, nil +} + +// publish puts a packed package into the registry unless it holds that version, and answers @. +func (m *Module) publish(ctx context.Context, root string, p packed) (string, error) { + ref := p.Name + "@" + p.Version + _, integrity, held, err := m.registryVersion(ctx, p.Name, p.Version) + if err != nil { + return "", err + } + if held { + if integrity == p.Integrity { + return ref, nil // this very file, published before: done without a publish + } + return "", fmt.Errorf("%s%s: the registry holds it as %s, and this build packs %s", delivery.VersionTaken, ref, integrity, p.Integrity) + } + file, err := os.CreateTemp(root, "publish-*.tgz") + if err != nil { + return "", err + } + defer os.Remove(file.Name()) // the packed file leaves the node once its publish answered + if _, err := file.Write(p.tgz); err != nil { + file.Close() + return "", err + } + if err := file.Close(); err != nil { + return "", err + } + npmrc, err := m.npmrcIn(root) + if err != nil { + return "", err + } + defer os.Remove(npmrc) + env := append(m.baseEnv(), "npm_config_cache="+m.npmCache()) + r := m.Run(ctx, env, "npm", "publish", file.Name(), "--ignore-scripts", "--userconfig", npmrc) + if why := ranFailed(r); why != "" { + return "", fmt.Errorf("publishing %s: %s", ref, why) + } + return ref, nil +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/package_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/package_integration_test.go new file mode 100644 index 00000000..9c7a9c14 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/package_integration_test.go @@ -0,0 +1,125 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// sdkFixture is a package named as the SDK at version 9.9.9 with no dependency, whose build script writes dist/: what +// the group's package member is to its TypeScript members. +var sdkFixture = map[string]string{ + "module.json": `{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]}}`, + "package.json": `{"name":"@novox/mesh-sdk","version":"9.9.9","type":"module","files":["dist"],"scripts":{"build":"node build.mjs"}}`, + "package-lock.json": `{"name":"@novox/mesh-sdk","version":"9.9.9","lockfileVersion":3,"requires":true,"packages":{"":{"name":"@novox/mesh-sdk","version":"9.9.9"}}}`, + "build.mjs": "import fs from 'node:fs';\nfs.mkdirSync('dist', {recursive: true});\nfs.writeFileSync('dist/index.js', 'export const v = \"9.9.9\";\\n');\n", +} + +// forgeRepo makes a bare repository o/ under root/forge holding files at one commit, and answers the commit. +func forgeRepo(t *testing.T, root, name string, files map[string]string) string { + t.Helper() + bare, work := filepath.Join(root, "forge", "o", name+".git"), filepath.Join(root, "work-"+name) + os.MkdirAll(bare, 0o755) + os.MkdirAll(work, 0o755) + git(t, bare, "init", "-q", "--bare") + git(t, work, "init", "-q", "-b", "main") + for n, c := range files { + write(t, filepath.Join(work, filepath.FromSlash(n)), c, 0o644) + } + git(t, work, "add", ".") + git(t, work, "commit", "-q", "-m", "c") + git(t, work, "push", "-q", bare, "HEAD:refs/heads/main") + return git(t, work, "rev-parse", "HEAD") +} + +// ADR 0500 §5 with the lead's decision L10, on real git, npm and bubblewrap: the fetch packs the group's package member +// (its build script and npm pack shut off, nothing published), and a member whose lock names that version with that +// integrity gets it in its node_modules; a member whose lock names it with another integrity fails naming both. +func TestTheFetchPacksTheGroupsPackageAndServesItToAMember(t *testing.T) { + for _, tool := range []string{"node", "npm", "git", "bwrap"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("NOT RUN: no %s where this check runs; every build node has it", tool) + } + } + root := t.TempDir() + sdkCommit := forgeRepo(t, root, "sdk", sdkFixture) + m, _, _ := newMachine(t) + m.Run = forgekey.ExecRunner + m.RemoteBase = "file://" + filepath.Join(root, "forge") + "/" + m.FetchOptions = []buildstep.Option{buildstep.AllowFileRemote()} + fakeRegistry(t, m, nil) // the registry holds no 9.9.9: only the group can serve it + // What the group's member packs to, as the member's lock would name it: packed once here the way the fetch packs it. + probe, err := m.fetch(context.Background(), map[string]any{"repository": "o/sdk", "commit": sdkCommit, + "package": map[string]any{"repository": "o/sdk", "commit": sdkCommit}}) + if err != nil { + t.Fatal(err) + } + group := strings.Fields(probe.(FetchResult).Group) + if len(group) != 2 || group[0] != "@novox/mesh-sdk@9.9.9" || !strings.HasPrefix(group[1], "sha512-") { + t.Fatalf("the fetch answered the group %q", probe.(FetchResult).Group) + } + member := func(integrity string) string { + lock, _ := json.Marshal(map[string]any{"name": "m", "lockfileVersion": 3, "requires": true, "packages": map[string]any{ + "": map[string]any{"name": "m", "dependencies": map[string]any{"@novox/mesh-sdk": "9.9.9"}}, + "node_modules/@novox/mesh-sdk": map[string]any{"version": "9.9.9", "integrity": integrity}}}) + files := typeScriptModule(`{"name":"m","dependencies":{"@novox/mesh-sdk":"9.9.9"}}`) + files["modules/m/package-lock.json"] = string(lock) + return forgeRepo(t, root, "m-"+integrity[len(integrity)-6:len(integrity)-2], files) + } + commit := member(group[1]) + name := "m-" + group[1][len(group[1])-6:len(group[1])-2] + out, err := m.fetch(context.Background(), map[string]any{"repository": "o/" + name, "commit": commit, + "package": map[string]any{"repository": "o/sdk", "commit": sdkCommit}}) + if err != nil { + t.Fatalf("a member locking the group's version: %v", err) + } + placed := filepath.Join(m.Work, out.(FetchResult).Workspace, "tree", "modules", "m", "node_modules", "@novox", "mesh-sdk") + if raw, err := os.ReadFile(filepath.Join(placed, "dist", "index.js")); err != nil || !strings.Contains(string(raw), "9.9.9") { + t.Errorf("the member's SDK is not the group's built package: %q %v", raw, err) + } + other := member("sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==") + _, err = m.fetch(context.Background(), map[string]any{"repository": "o/m-AAAA", "commit": other, + "package": map[string]any{"repository": "o/sdk", "commit": sdkCommit}}) + if err == nil || !strings.Contains(err.Error(), group[1]) { + t.Errorf("a member locking another integrity answered %v", err) + } +} + +// ADR 0499 §4 (issue 1758): a fetch asked with the module's directory and its named paths leaves only those, the +// manifest and the lock files; another module of the commit is not listed or installed. +func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) { + root := t.TempDir() + files := typeScriptModule(`{"name":"m"}`) + files["modules/m/tools/index.ts"] = "export {}\n" + files["modules/m/docs/a.md"] = "not named\n" + files["modules/other/module.json"] = files["modules/m/module.json"] + files["README.md"] = "the repository\n" + commit := forgeRepo(t, root, "narrow", files) + m, _, _ := newMachine(t) + m.Run = forgekey.ExecRunner + m.RemoteBase = "file://" + filepath.Join(root, "forge") + "/" + m.FetchOptions = []buildstep.Option{buildstep.AllowFileRemote()} + out, err := m.fetch(context.Background(), map[string]any{"repository": "o/narrow", "commit": commit, "dir": "modules/m", "paths": []any{"tools"}}) + if err != nil { + t.Fatal(err) + } + r := out.(FetchResult) + tree := filepath.Join(m.Work, r.Workspace, "tree") + for _, gone := range []string{"README.md", "modules/m/docs/a.md", "modules/other/module.json"} { + if _, err := os.Stat(filepath.Join(tree, gone)); err == nil { + t.Errorf("%s is still in the workspace", gone) + } + } + if _, err := os.Stat(filepath.Join(tree, "modules/m/tools/index.ts")); err != nil || strings.Join(r.Modules, ",") != "modules/m" { + t.Errorf("the named path or the module went: %v, modules %v", err, r.Modules) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/package_test.go b/modules/mesh-build-node/cmd/mesh-build-node/package_test.go new file mode 100644 index 00000000..58000bd8 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/package_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// tgzOf is a packed npm package holding package/package.json of name@version and one more file. +func tgzOf(t *testing.T, name, version string) []byte { + t.Helper() + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + tw := tar.NewWriter(zw) + for _, f := range [][2]string{{"package/package.json", `{"name":"` + name + `","version":"` + version + `"}`}, {"package/dist/index.js", "export const v = '" + version + "';\n"}} { + n, body := f[0], f[1] + tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}) + tw.Write([]byte(body)) + } + tw.Close() + zw.Close() + return buf.Bytes() +} + +// fakeRegistry serves the packument and the packed files of the versions it holds; asked counts its answers. +func fakeRegistry(t *testing.T, m *Module, held map[string][]byte) *atomic.Int32 { + t.Helper() + var asked atomic.Int32 + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + asked.Add(1) + switch { + case r.URL.Path == "/npm/@novox%2fmesh-sdk" || r.URL.Path == "/npm/@novox/mesh-sdk": + if len(held) == 0 { + http.NotFound(w, r) + return + } + versions := map[string]any{} + for v, tgz := range held { + versions[v] = map[string]any{"dist": map[string]any{"tarball": srv.URL + "/files/" + v + ".tgz", "integrity": integrityOf(tgz)}} + } + json.NewEncoder(w).Encode(map[string]any{"versions": versions}) + case strings.HasPrefix(r.URL.Path, "/files/"): + v := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/files/"), ".tgz") + if tgz, ok := held[v]; ok { + w.Write(tgz) + return + } + http.NotFound(w, r) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + m.Toolchain.Registry = func() (Registry, error) { return Registry{URL: srv.URL + "/npm/"}, nil } + m.HTTP = srv.Client() + return &asked +} + +// memberLocking is a member's directory whose lock names @novox/mesh-sdk@version with integrity (none when version is ""). +func memberLocking(t *testing.T, version, integrity string) string { + t.Helper() + dir := t.TempDir() + lock := map[string]any{"lockfileVersion": 3, "packages": map[string]any{"": map[string]any{"name": "m"}}} + if version != "" { + lock["packages"].(map[string]any)["node_modules/@novox/mesh-sdk"] = map[string]any{"version": version, "integrity": integrity} + } + raw, _ := json.Marshal(lock) + write(t, filepath.Join(dir, "package-lock.json"), string(raw), 0o644) + return dir +} + +func placedVersion(dir string) string { + raw, err := os.ReadFile(filepath.Join(dir, "node_modules", "@novox", "mesh-sdk", "package.json")) + if err != nil { + return "" + } + var p packageJSON + json.Unmarshal(raw, &p) + return p.Version +} + +// The lead's decision L10 (ADR 0500 §6): a member is built against the SDK version its own lock names, downloaded from +// the registry and checked against the lock's integrity; a version the registry does not hold is a wait, `waits for:`. +func TestAMemberGetsTheSDKVersionItsLockNamesOrWaitsForIt(t *testing.T) { + m, _, _ := newMachine(t) + held := tgzOf(t, sdkPackage, "0.1.15") + fakeRegistry(t, m, map[string][]byte{"0.1.15": held}) + dir := memberLocking(t, "0.1.15", integrityOf(held)) + if err := m.placeSDK(context.Background(), dir, "modules/m", nil); err != nil || placedVersion(dir) != "0.1.15" { + t.Errorf("the registry's 0.1.15: %v, placed %q", err, placedVersion(dir)) + } + dir = memberLocking(t, "0.1.16", "sha512-x") + err := m.placeSDK(context.Background(), dir, "modules/m", nil) + if err == nil || err.Error() != delivery.WaitsFor+"@novox/mesh-sdk@0.1.16" { + t.Errorf("a version the registry lacks answered %v, want %s@novox/mesh-sdk@0.1.16", err, delivery.WaitsFor) + } + dir = memberLocking(t, "0.1.15", "sha512-another") + if err := m.placeSDK(context.Background(), dir, "modules/m", nil); err == nil || !strings.Contains(err.Error(), "sha512-another") { + t.Errorf("a registry file of another integrity than the lock's answered %v", err) + } +} + +// ADR 0500 §5 (S3, S4): the group's packed package is served in place of the registry's when the member's lock names its +// version with its integrity; another integrity, or a version neither the group nor the registry holds, fails naming both. +// A member whose lock names no SDK keeps the toolchain's and asks the registry nothing. +func TestTheGroupsPackageIsServedToAMemberLockingItsVersion(t *testing.T) { + m, _, _ := newMachine(t) + asked := fakeRegistry(t, m, map[string][]byte{"0.1.14": tgzOf(t, sdkPackage, "0.1.14")}) + tgz := tgzOf(t, sdkPackage, "0.1.15") + group := &packed{Name: sdkPackage, Version: "0.1.15", Integrity: integrityOf(tgz), Digest: digestOf(tgz), tgz: tgz} + dir := memberLocking(t, "0.1.15", group.Integrity) + if err := m.placeSDK(context.Background(), dir, "modules/m", group); err != nil || placedVersion(dir) != "0.1.15" || asked.Load() != 0 { + t.Errorf("the group's own version: %v, placed %q, the registry asked %d times", err, placedVersion(dir), asked.Load()) + } + dir = memberLocking(t, "0.1.15", "sha512-other") + if err := m.placeSDK(context.Background(), dir, "modules/m", group); err == nil || !strings.Contains(err.Error(), "sha512-other") || !strings.Contains(err.Error(), group.Integrity) { + t.Errorf("another integrity answered %v, want both named", err) + } + dir = memberLocking(t, "0.1.14", integrityOf(tgzOf(t, sdkPackage, "0.1.14"))) + if err := m.placeSDK(context.Background(), dir, "modules/m", group); err != nil || placedVersion(dir) != "0.1.14" { + t.Errorf("a member locking the registry's 0.1.14 in a group: %v, placed %q", err, placedVersion(dir)) + } + dir = memberLocking(t, "0.1.13", "sha512-x") + if err := m.placeSDK(context.Background(), dir, "modules/m", group); err == nil || !strings.Contains(err.Error(), "0.1.13") || !strings.Contains(err.Error(), "0.1.15") || strings.HasPrefix(err.Error(), delivery.WaitsFor) { + t.Errorf("a version neither holds answered %v, want both named and no wait", err) + } + before := asked.Load() + dir = memberLocking(t, "", "") + if err := m.placeSDK(context.Background(), dir, "modules/m", group); err != nil || placedVersion(dir) != "" || asked.Load() != before { + t.Errorf("a lock naming no SDK: %v, placed %q, the registry asked", err, placedVersion(dir)) + } +} + +// The fetch's `package` argument is a repository and a full commit, with a pull request's head at most; anything else is +// refused before anything is fetched. +func TestTheGroupPackageArgumentIsRefusedWhenItIsNotOne(t *testing.T) { + for _, bad := range []any{"novox/mesh-sdk", map[string]any{"repository": "novox/mesh-sdk", "commit": "main"}, + map[string]any{"repository": "../x", "commit": strings.Repeat("a", 40)}, + map[string]any{"repository": "novox/mesh-sdk", "commit": strings.Repeat("a", 40), "ref": "refs/heads/main"}} { + if _, err := groupPackageArg(bad); err == nil { + t.Errorf("%v was taken", bad) + } + } + g, err := groupPackageArg(map[string]any{"repository": "novox/mesh-sdk", "commit": strings.Repeat("a", 40), "ref": "refs/pull/9/head"}) + if err != nil || g.Ref != "refs/pull/9/head" { + t.Errorf("a pull request's head: %+v %v", g, err) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/publish_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/publish_integration_test.go new file mode 100644 index 00000000..50882d7e --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/publish_integration_test.go @@ -0,0 +1,121 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// npmRegistry is a registry of the test's own speaking npm's publish protocol: a PUT of the package's document with the +// packed file attached, then GET of the document and the file; it asks for the account's credential. +type npmRegistry struct { + mu sync.Mutex + versions map[string]map[string]any + files map[string][]byte + publishes int +} + +func (r *npmRegistry) serve(t *testing.T) *httptest.Server { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, q *http.Request) { + r.mu.Lock() + defer r.mu.Unlock() + if u, p, ok := q.BasicAuth(); !ok || u != "builder" || p != "the write credential" { + w.WriteHeader(http.StatusUnauthorized) + return + } + doc := strings.TrimPrefix(q.URL.EscapedPath(), "/npm/") + switch { + case q.Method == http.MethodPut && (doc == "@novox%2fmesh-sdk" || doc == "@novox%2Fmesh-sdk"): + var body struct { + Versions map[string]map[string]any `json:"versions"` + Attachments map[string]struct { + Data string `json:"data"` + } `json:"_attachments"` + } + raw, _ := io.ReadAll(q.Body) + if err := json.Unmarshal(raw, &body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + for v, meta := range body.Versions { + for name, a := range body.Attachments { + data, _ := base64.StdEncoding.DecodeString(a.Data) + r.files[name] = data + meta["dist"] = map[string]any{"tarball": srv.URL + "/files/" + name, "integrity": integrityOf(data)} + } + r.versions[v] = meta + } + r.publishes++ + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"ok":true}`)) + case q.Method == http.MethodGet && (doc == "@novox%2fmesh-sdk" || doc == "@novox%2Fmesh-sdk"): + if len(r.versions) == 0 { + http.NotFound(w, q) + return + } + json.NewEncoder(w).Encode(map[string]any{"name": "@novox/mesh-sdk", "versions": r.versions}) + case strings.HasPrefix(q.URL.Path, "/files/"): + w.Write(r.files[strings.TrimPrefix(q.URL.Path, "/files/")]) + default: + http.NotFound(w, q) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// 1587-S20 (ADR 0498 §5): the real npm publishes a trunk build's package to a registry speaking npm's protocol, with +// the module's credential, once: a second trunk build of the same package finds its file there and publishes nothing; +// a candidate publishes nothing at all. +func TestARealTrunkPackageIsPublishedOnce(t *testing.T) { + for _, tool := range []string{"node", "npm", "bwrap"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("NOT RUN: no %s where this check runs; every build node has it", tool) + } + } + m, _, work := newMachine(t) + m.Run = forgekey.ExecRunner + reg := &npmRegistry{versions: map[string]map[string]any{}, files: map[string][]byte{}} + srv := reg.serve(t) + m.Toolchain.Registry = func() (Registry, error) { + return Registry{URL: srv.URL + "/npm/", Username: "builder", Password: "the write credential"}, nil + } + m.HTTP = srv.Client() + files := map[string]string{} + for n, c := range sdkFixture { + files[n] = c + } + workspaceWith(t, work, files) + dir := filepath.Join(work, "w1", "tree") + if err := m.installDev(context.Background(), filepath.Join(work, "w1"), dir, "."); err != nil { + t.Fatal(err) + } + args := map[string]any{"workspace": "w1", "module": ".", "artifact": "lib"} + if out, err := m.build(context.Background(), args); err != nil || out.(BuildResult).Reference != "" || reg.publishes != 0 { + t.Fatalf("a candidate: %+v %v, %d publishes", out, err, reg.publishes) + } + args["publish"] = true + out, err := m.build(context.Background(), args) + if err != nil || out.(BuildResult).Reference != "@novox/mesh-sdk@9.9.9" || reg.publishes != 1 { + t.Fatalf("a trunk build: %+v %v, %d publishes", out, err, reg.publishes) + } + if got := digestOf(reg.files["@novox/mesh-sdk-9.9.9.tgz"]); got != out.(BuildResult).Digest { + t.Errorf("the registry holds %s, the build packed %s (files %d)", got, out.(BuildResult).Digest, len(reg.files)) + } + if again, err := m.build(context.Background(), args); err != nil || again.(BuildResult).Reference != "@novox/mesh-sdk@9.9.9" || reg.publishes != 1 { + t.Errorf("the same package again: %+v %v, %d publishes", again, err, reg.publishes) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/publish_test.go b/modules/mesh-build-node/cmd/mesh-build-node/publish_test.go new file mode 100644 index 00000000..e1ab72cb --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/publish_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const packageManifest = `{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","package.json"]}]}}` + +// packageMachine is a machine whose workspace w1 holds the package @novox/mesh-sdk@0.1.15 at its root; npm pack writes +// the packed file tgz into its --pack-destination, as npm does. +func packageMachine(t *testing.T, tgz []byte) (*Module, *fakeMachine, string) { + t.Helper() + m, f, work := newMachine(t) + workspaceWith(t, work, map[string]string{"module.json": packageManifest, "src/index.ts": "export const v = 1;\n", + "package.json": `{"name":"@novox/mesh-sdk","version":"0.1.15","scripts":{"build":"tsc"}}`, "package-lock.json": `{"lockfileVersion":3}`}) + f.effect = func(c call) { + if c.name != "npm" || len(c.args) == 0 || c.args[0] != "pack" { + return + } + for i, a := range c.args { + if a == "--pack-destination" { + write(t, filepath.Join(c.args[i+1], "novox-mesh-sdk-0.1.15.tgz"), string(tgz), 0o644) + } + } + } + return m, f, work +} + +func publishes(f *fakeMachine) []call { + var out []call + for _, c := range f.calls { + if c.name == "npm" && len(c.args) > 0 && c.args[0] == "publish" { + out = append(out, c) + } + } + return out +} + +// 1587-S15..S19 (ADR 0498 §5): a package is built by its own build script and packed, shut off; a candidate answers the +// packed file's digest, publishes nothing and asks the registry nothing; its reference is empty. +func TestACandidatesPackageIsBuiltAndPackedAndNeverPublished(t *testing.T) { + tgz := tgzOf(t, sdkPackage, "0.1.15") + m, f, _ := packageMachine(t, tgz) + asked := fakeRegistry(t, m, nil) + out, err := m.build(context.Background(), map[string]any{"workspace": "w1", "module": ".", "artifact": "lib"}) + if err != nil { + t.Fatal(err) + } + r := out.(BuildResult) + if r.Digest != digestOf(tgz) || r.Reference != "" || r.Bytes != len(tgz) { + t.Errorf("result %+v, want the packed file's digest and no reference", r) + } + if len(publishes(f)) != 0 || asked.Load() != 0 { + t.Errorf("a candidate published %d times and asked the registry %d times", len(publishes(f)), asked.Load()) + } + var ran []string + for _, s := range f.shut { + ran = append(ran, s.command) + } + if len(f.shut) != 2 || !strings.Contains(ran[0], "npm run build") || !strings.Contains(ran[1], "npm pack --ignore-scripts") { + t.Errorf("shut off %v, want the build and the pack", ran) + } +} + +// ADR 0498 §5: a trunk build (publish) publishes a version the registry lacks and answers @; the same +// packed file already there is done without a publish; another file under that version fails `version taken:`. The +// packed file is gone from the node afterwards. +func TestATrunkPackageIsPublishedOnceAndAVersionTakenIsRefused(t *testing.T) { + tgz := tgzOf(t, sdkPackage, "0.1.15") + args := map[string]any{"workspace": "w1", "module": ".", "artifact": "lib", "publish": true} + m, f, work := packageMachine(t, tgz) + fakeRegistry(t, m, nil) + out, err := m.build(context.Background(), args) + if err != nil || out.(BuildResult).Reference != "@novox/mesh-sdk@0.1.15" || len(publishes(f)) != 1 { + t.Fatalf("an absent version: %+v %v, %d publishes", out, err, len(publishes(f))) + } + if p := publishes(f)[0]; !slices.Contains(p.args, "--ignore-scripts") || !strings.HasSuffix(p.args[1], ".tgz") { + t.Errorf("published with %v", p.args) + } + // ADR 0500 §4: the registry's credential is never in a workspace, which a run step is given. + for i, a := range publishes(f)[0].args { + if a == "--userconfig" && strings.HasPrefix(publishes(f)[0].args[i+1], work) { + t.Errorf("the credential was written into the workspace: %s", publishes(f)[0].args[i+1]) + } + } + var left []string + filepath.WalkDir(work, func(p string, d os.DirEntry, err error) error { + if err == nil && strings.HasSuffix(p, ".tgz") || strings.Contains(p, ".npmrc") { + left = append(left, p) + } + return nil + }) + if len(left) != 0 { + t.Errorf("left on the node: %v", left) + } + + m, f, _ = packageMachine(t, tgz) + fakeRegistry(t, m, map[string][]byte{"0.1.15": tgz}) + if out, err := m.build(context.Background(), args); err != nil || out.(BuildResult).Reference != "@novox/mesh-sdk@0.1.15" || len(publishes(f)) != 0 { + t.Errorf("the same file published before: %+v %v, %d publishes", out, err, len(publishes(f))) + } + + m, f, _ = packageMachine(t, tgz) + fakeRegistry(t, m, map[string][]byte{"0.1.15": tgzOf(t, sdkPackage, "another")}) + _, err = m.build(context.Background(), args) + if err == nil || !strings.HasPrefix(err.Error(), delivery.VersionTaken+"@novox/mesh-sdk@0.1.15") || len(publishes(f)) != 0 { + t.Errorf("another file under the version: %v, %d publishes", err, len(publishes(f))) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/push.go b/modules/mesh-build-node/cmd/mesh-build-node/push.go new file mode 100644 index 00000000..95291c52 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/push.go @@ -0,0 +1,42 @@ +package main + +// The push of a done build's archive (novox/hq ADR 0498 §1 §2; issue 1587): from this module's own process, after the +// build step ended, with mesh-sdk's one client (buildstep.PushBlob), to the artifact store whose address the manifest +// binds into RegistryFile. No build step reaches the store: a run step has no network (ADR 0448). + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" +) + +// registry is the artifact store's address, host:port, as the manifest bound it into RegistryFile. +func (m *Module) registry() (string, error) { + raw, err := os.ReadFile(m.RegistryFile) + if err != nil { + return "", fmt.Errorf("the artifact store's address cannot be read: %w", err) + } + addr := strings.TrimSpace(string(raw)) + if addr == "" || strings.Contains(addr, "${") || strings.HasPrefix(addr, ":") || strings.HasSuffix(addr, ":") { + return "", fmt.Errorf("the artifact store's address in %s is %q, not host:port", m.RegistryFile, addr) + } + return addr, nil +} + +// push puts body into the artifact store as a blob of repository (/) by its digest: Push when a test +// set it, else buildstep.PushBlob. +func (m *Module) push(ctx context.Context, repository, digest string, body []byte) error { + addr, err := m.registry() + if err != nil { + return err + } + if m.Push != nil { + return m.Push(ctx, addr, repository, digest, body) + } + return buildstep.PushBlob(ctx, &http.Client{Timeout: 10 * time.Minute}, addr, repository, digest, body) +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/push_test.go b/modules/mesh-build-node/cmd/mesh-build-node/push_test.go new file mode 100644 index 00000000..50cbe7e9 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/push_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// pushed are the blobs the fake artifact store took, by digest, for the tests that read an archive back. +var pushed sync.Map + +// fakeStore is a store of the test's own: its address in RegistryFile, each blob kept by repository and digest; fail, +// when set, is the store's refusal. +type fakeStore struct { + mu sync.Mutex + blobs map[string][]byte // "@" + fail error +} + +func withStore(t *testing.T, m *Module) *fakeStore { + t.Helper() + s := &fakeStore{blobs: map[string][]byte{}} + m.RegistryFile = filepath.Join(t.TempDir(), "registry") + if err := os.WriteFile(m.RegistryFile, []byte("store.test:5000\n"), 0o644); err != nil { + t.Fatal(err) + } + m.Push = func(ctx context.Context, registry, repository, digest string, body []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.fail != nil { + return s.fail + } + if sum := sha256.Sum256(body); "sha256:"+hex.EncodeToString(sum[:]) != digest || registry != "store.test:5000" { + t.Errorf("pushed %s to %s with bytes of another digest", digest, registry) + } + s.blobs[repository+"@"+digest] = append([]byte(nil), body...) + pushed.Store(digest, s.blobs[repository+"@"+digest]) + return nil + } + return s +} + +// archiveOf is the archive a build pushed, by its digest. +func archiveOf(t *testing.T, r BuildResult) []byte { + t.Helper() + raw, ok := pushed.Load(r.Digest) + if !ok { + t.Fatalf("nothing was pushed under %s", r.Digest) + } + return raw.([]byte) +} + +// 1587-S6 (ADR 0498 §1): a done bundle answers the reference of the archive it pushed, under the manifest's module. +func TestADoneBundleAnswersTheReferenceOfTheArchiveItPushed(t *testing.T) { + m, _, _ := builtMachine(t, ``) + store := withStore(t, m) + out, err := m.build(context.Background(), buildArgs()) + if err != nil { + t.Fatal(err) + } + r := out.(BuildResult) + if want := "artifact-store://m/tools/blobs/" + r.Digest; r.Reference != want { + t.Errorf("reference %q, want %q", r.Reference, want) + } + if store.blobs["m/tools@"+r.Digest] == nil { + t.Errorf("the store holds %d blobs, not m/tools@%s", len(store.blobs), r.Digest) + } +} + +// 1587-S7 and S8 (ADR 0498 §2, the lead's decision L9): a push the store refuses fails the step `not stored: +// : `, and whatever the push answered, no packed file is left on the node. +func TestARefusedPushFailsNotStoredAndNoPackedFileStays(t *testing.T) { + for _, fail := range []error{nil, errors.New("store.test:5000 refused the blob: 507 Insufficient Storage")} { + m, _, work := builtMachine(t, ``) + withStore(t, m).fail = fail + _, err := m.build(context.Background(), buildArgs()) + if fail != nil && (err == nil || !strings.HasPrefix(err.Error(), delivery.NotStored+"tools: ") || !strings.Contains(err.Error(), "507")) { + t.Errorf("a refused push answered %v", err) + } + var archives []string + filepath.WalkDir(work, func(p string, d os.DirEntry, err error) error { + if err == nil && strings.HasSuffix(p, ".tar.gz") { + archives = append(archives, p) + } + return nil + }) + if len(archives) != 0 { + t.Errorf("push answered %v: packed files left on the node: %v", fail, archives) + } + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/recipe.go b/modules/mesh-build-node/cmd/mesh-build-node/recipe.go index c83fead8..ea3a8eb7 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/recipe.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/recipe.go @@ -14,7 +14,7 @@ import ( // RecipeSteps is the recipe's own version: changed whenever the steps of fetch, test or build change in a way that // can change what they produce (ADR 0405 §7). It is one of the two values the recipe version hashes. -const RecipeSteps = "1" +const RecipeSteps = "2" // 2: a member is built against the SDK its own lock names (R4e, the lead's decision L10) // Recipe is every value that makes up the recipe version. type Recipe struct { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/registry.go b/modules/mesh-build-node/cmd/mesh-build-node/registry.go index b4a2bc66..e7b8e859 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/registry.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/registry.go @@ -12,6 +12,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" "os" "strings" ) @@ -79,3 +80,10 @@ func (r Registry) Npmrc() string { } return sb.String() } + +// authorize gives a request to the registry the account's credential, when there is one. +func (r Registry) authorize(req *http.Request) { + if r.Username != "" && r.Password != "" { + req.SetBasicAuth(r.Username, r.Password) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/store_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/store_integration_test.go new file mode 100644 index 00000000..372330bb --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/store_integration_test.go @@ -0,0 +1,77 @@ +//go:build integration + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/distribution/distribution/v3/configuration" + "github.com/distribution/distribution/v3/registry/handlers" + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// realStore starts the artifact store's own program (the distribution registry, modules/distribution) inside this +// test, with its in-memory storage, on a loopback port, and points m at it with the real push client. It is imported +// by this integration test alone: the module's program does not carry it. +func realStore(t *testing.T, m *Module) string { + t.Helper() + config := &configuration.Configuration{Storage: configuration.Storage{"inmemory": configuration.Parameters{}}} + config.HTTP.Secret = "a throwaway store" + config.Log.Level = "error" + config.Log.AccessLog.Disabled = true + srv := httptest.NewServer(handlers.NewApp(context.Background(), config)) + t.Cleanup(srv.Close) + addr := strings.TrimPrefix(srv.URL, "http://") + m.RegistryFile = filepath.Join(t.TempDir(), "registry") + if err := os.WriteFile(m.RegistryFile, []byte(addr+"\n"), 0o644); err != nil { + t.Fatal(err) + } + m.Push = nil + return addr +} + +// 1587-S10 (ADR 0498's check): a done bundle names a reference whose tail is its digest, and that reference resolves in +// a real artifact store to bytes of that digest. The build is the real tsc's and esbuild's, shut off. +func TestADoneBundleResolvesInARealArtifactStore(t *testing.T) { + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("NOT RUN: no bubblewrap installed where this check runs; every build node has it (the module declares it)") + } + m, _ := realMachine(t) + addr := realStore(t, m) + out, err := m.build(context.Background(), map[string]any{"workspace": "w1", "module": "modules/m", "artifact": "tools"}) + if err != nil { + t.Fatal(err) + } + r := out.(BuildResult) + done := delivery.BuilderBuild{V: 1, State: delivery.BuildDone, Manifest: strings.Repeat("0", 64), + Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: r.Digest, Reference: r.Reference}}} + if _, err := delivery.EncodeBuilderBuild(done); err != nil { + t.Errorf("the done key the builder would write is refused: %v", err) + } + path, ok := strings.CutPrefix(r.Reference, "artifact-store://") + if !ok || !strings.HasSuffix(r.Reference, "/blobs/"+r.Digest) { + t.Fatalf("the reference %q", r.Reference) + } + resp, err := http.Get("http://" + addr + "/v2/" + path) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + sum := sha256.Sum256(body) + if resp.StatusCode != http.StatusOK || "sha256:"+hex.EncodeToString(sum[:]) != r.Digest { + t.Errorf("the store answered %s with bytes of sha256:%x for %s", resp.Status, sum, r.Reference) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/workspace.go b/modules/mesh-build-node/cmd/mesh-build-node/workspace.go index 001dec70..c49f6cc7 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/workspace.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/workspace.go @@ -168,10 +168,16 @@ func copyFile(from, to string) error { return out.Close() } -// runIn runs a command with dir as its working directory (`env -C`), so it is the plain line the build agent runs from -// the module's root, with the same relative paths. The command's environment is baseEnv and extra. -func (m *Module) runIn(ctx context.Context, dir string, extra []string, name string, args ...string) forgekey.Ran { - return m.Run(ctx, append(m.baseEnv(), extra...), "env", append([]string{"-C", dir, name}, args...)...) +// runIn runs a command of a run step shut off (stepRunner) with dir as its working directory (`env -C`), so it is the +// plain line the build agent runs from the module's root, with the same relative paths. The command's environment is +// a PATH, a locale and extra: never the account's HOME (the step's is its own) nor this process's environment. root is +// the workspace; write is a trunk build's (publish), which alone writes the shared cache. +func (m *Module) runIn(ctx context.Context, root string, write bool, dir string, extra []string, name string, args ...string) forgekey.Ran { + run, err := m.stepRunner(root, write) + if err != nil { + return forgekey.Ran{Status: 1, Err: err.Error()} + } + return run(ctx, extra, "env", append([]string{"-C", dir, name}, args...)...) } // ranFailed says why a command did not pass, or "" when it did. diff --git a/modules/mesh-build-node/files/registry b/modules/mesh-build-node/files/registry new file mode 100644 index 00000000..1bcadab2 --- /dev/null +++ b/modules/mesh-build-node/files/registry @@ -0,0 +1 @@ +${seat:mesh-artifact-store:reach} diff --git a/modules/mesh-build-node/go.mod b/modules/mesh-build-node/go.mod index 4b453f9b..bbdce43b 100644 --- a/modules/mesh-build-node/go.mod +++ b/modules/mesh-build-node/go.mod @@ -2,4 +2,81 @@ module meshbuildnode go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 + github.com/distribution/distribution/v3 v3.1.2 + github.com/nats-io/nats.go v1.54.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect + github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect + github.com/docker/go-metrics v0.1.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/handlers v1.5.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nats-server/v2 v2.11.17 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect + github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect + github.com/redis/go-redis/v9 v9.7.3 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect + go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect + go.opentelemetry.io/otel/log v0.21.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/sdk v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.21.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/modules/mesh-build-node/go.sum b/modules/mesh-build-node/go.sum index 8097dd56..7186927c 100644 --- a/modules/mesh-build-node/go.sum +++ b/modules/mesh-build-node/go.sum @@ -1,6 +1,178 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5 h1:1BiPKRPTYpgex8rSHWT9az0BteOafGWRDzd/Ba6GAcA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.1.2 h1:/Bv2YIqqSR00HiO49FT93hovy6r9IlrzwUDsJjCN5qg= +github.com/distribution/distribution/v3 v3.1.2/go.mod h1:Oz0Drs2Tx3iTXzK3DhUD4otfTfqlCxlWVC7QJeXWBP0= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-events v0.0.0-20250808211157-605354379745 h1:yOn6Ze6IbYI/KAw2lw/83ELYvZh6hvsygTVkD0dzMC4= +github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.1.0 h1:r76KPNpstz+IvQKSWpYegSkkyzex0V3A1ZGVx6bhGlY= +github.com/docker/go-metrics v0.1.0/go.mod h1:PciI3sONtB051kXALN1JoIlpcu54E1FuPh+4DuqEzyw= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= +github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= +github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +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.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= +github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 h1:WseeVYf5dJZTsyPiyW5L14k5qsSibqXAMTSiFEDiWr0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0/go.mod h1:SiLZnQS6Qk2eCpvr2CH/XMAOa64TWGXxEZJZCpD2Lmc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 h1:fvNHGyo3CdRv/DQveXqhqBxnKTDyRaC5sMSQxilX/A0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0/go.mod h1:zyGrjRKL2B/6+Jc/m4/otPoZqV2MY9ZjC/aBraRO7zc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 h1:2lpf4hnrasYIsUyEXwnTZq5lsxrMm4T2Bwb06IctAZQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0/go.mod h1:YWOW6h7jwApz9Pl76ie/izUsSPj0s2MdIlpqbPqaf3U= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA= +go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/log v0.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc= +go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0/go.mod h1:HD1575K8e6sIFBBDd5tZB3t9DlMytWXq9FuR+Y4rfjE= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index b92212aa..c73aa9a1 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -33,11 +33,12 @@ "requires": [ "forge-account", "npm-package-registry", - "node-toolchain" + "node-toolchain", + "artifact-store" ], "contributes": { "npm-package-registry": { - "access": "read" + "access": "write" } }, "binds": { @@ -122,6 +123,14 @@ "mode": "0644", "template": "files/gitconfig" }, + { + "id": "registry", + "type": "file", + "path": "${dir:state}/registry", + "owner": "mesh-build-node", + "mode": "0644", + "template": "files/registry" + }, { "id": "toolchain-dir", "type": "archive", @@ -139,6 +148,11 @@ "id": "openssh", "type": "package", "package": "openssh" + }, + { + "id": "bubblewrap", + "type": "package", + "package": "bubblewrap" } ], "build": { diff --git a/modules/mesh-catalog/package-lock.json b/modules/mesh-catalog/package-lock.json new file mode 100644 index 00000000..a70fb429 --- /dev/null +++ b/modules/mesh-catalog/package-lock.json @@ -0,0 +1,161 @@ +{ + "name": "@novox/module-mesh-catalog", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@novox/module-mesh-catalog", + "version": "0.1.0", + "dependencies": { + "pg": "^8" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr350_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr350_test.go index 05075466..9aba0cd9 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr350_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr350_test.go @@ -1,7 +1,6 @@ package main import ( - "reflect" "strings" "testing" "time" @@ -10,14 +9,15 @@ import ( // One build per change (novox/hq ADR 0350): a pull request closed unmerged has its check builds let go of, and a // walk's record says per module whether its build reused a check's build or built, and why. -// A pull request closed unmerged asks the controller to let go of what its checks built for its merge (§5); one -// the controller cannot answer is said, never fatal. -func TestAPullRequestClosedUnmergedLetsGoOfItsCheckBuilds(t *testing.T) { +// A pull request closed unmerged is stopped. Since issue 1318 (ADR 0503 §3) the controller is no longer asked to let go +// of what its checks built (§5's forget-checks goes with the merge gate's checks); a controller that is down changes +// nothing. +func TestAPullRequestClosedUnmergedIsStoppedAndAsksTheControllerNothing(t *testing.T) { w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atCC) cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json") w.h.PullUpdated(cc) w.h.PullClosed(cc) - if want := []string{"novox/mesh-catalog#101"}; !reflect.DeepEqual(w.ctl.forgot, want) { + if len(w.ctl.forgot) != 0 { t.Fatalf("closed unmerged, the controller was asked to let go of %v", w.ctl.forgot) } if s := w.state(IDOf(cat, headCC)); s != Stopped { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go index fab99083..b0c33f30 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go @@ -18,6 +18,7 @@ import ( // Context names of the statuses this owner can say more of than the forge keeps. const ( + // ctxGate is the controller's merge gate, kept where a base still requires it; mesh/check is ctxCheck (onestatus.go). ctxGate = "mesh/merge-gate" ctxRepo = "mesh/repo-check" ctxDelivery = "mesh/delivery" @@ -230,6 +231,10 @@ func (h *Holder) addWhatIsKnown(s *CheckStatus, v *Verdict, d *checksDelivery) { } else if v.Repo != "" { s.Verdict = strings.ToUpper(v.Repo) + " — " + v.RepoSaid } + case ctxCheck: + if v != nil && v.Status == ctxCheck { + s.Verdict = strings.ToUpper(firstOf(v.Gate, "error")) + " — " + v.Summary + } case ctxDelivery: if d != nil { s.Verdict = string(d.State) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/configheld.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/configheld.go index 3d40e5bc..51a03475 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/configheld.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/configheld.go @@ -103,6 +103,13 @@ func (h *Holder) askProposedAgain(id, why string) { h.mu.Unlock() return } + if d.judgedByCheck() { + // The checker's (issue 1302): its check is not asked here, and whether a held config value is weighed is the + // checker's composition's. + h.Logf("[mesh-delivery] %s: a held config value changed; %v", id, checkerRefusal(id)) + h.mu.Unlock() + return + } owner, repo, commit, page, number := d.Owner(), d.Repo(), d.Commit, d.HTMLURL, d.Number h.mu.Unlock() if err := h.checkingAgain(owner, repo, number, commit, page, why); err != nil { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go index 8eb4bab5..e8f6c0f2 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go @@ -19,6 +19,9 @@ type ForgeStatusEvent struct { SHA string `json:"sha"` Context string `json:"context"` State string `json:"state"` + // Description is the status's words (the gitea module's status event carries them since issue 1302): mesh/check's + // line names the head and the trunk it judged. + Description string `json:"description,omitempty"` } // wantForge says the forge's open pull requests are to be read at the next look: a check may have answered. @@ -44,7 +47,8 @@ func (h *Holder) AnswersChanged() { } } -// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/merge-gate, mesh/repo-check) says a +// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/check; hq's mesh/repo-check and, on a +// base that still requires it, mesh/merge-gate) says a // check started, answered, or was put back, so the forge's open pull requests are read to see which heads still wait on // one (SeeOpenPulls). A status of any other context shows nothing the merge needs. // @@ -60,6 +64,10 @@ func (h *Holder) CommitStatus(e ForgeStatusEvent) { h.wantForge() h.mu.Lock() defer h.mu.Unlock() + if e.Context == ctxCheck && (e.Description != "" || e.State == "pending") { + // mesh/check is the verdict on its base (issue 1302): taken at once, as the controller's `checked` was. + h.takeCheckStatus(e.Owner+"/"+e.Repo, e.SHA, e.State, e.Description) + } if e.State != "success" { h.withdrawForStatus(e) return diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go index fa731a8e..55e7cbdb 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -78,6 +78,9 @@ type Holder struct { refusals []string // unchecked are the open pull requests' heads seen without a merge check (unchecked.go). unchecked map[string]unchecked + // checker are the checker's verdicts by the hash of the heads they judged, as its verdicts bucket holds them + // (onestatus.go, issue 1302). + checker map[string]checkerVerdict // walks are the walks' and batches' last records (answers.go); orphans the waiting walks no live delivery // holds, by when that was first seen; turning the walks whose turn came this look, for some deliveries. walks map[string]seenWalk @@ -170,6 +173,7 @@ func (h *Holder) init() { h.lostRead = map[string]time.Time{} h.walkAsked = map[string]time.Time{} h.unchecked = map[string]unchecked{} + h.checker = map[string]checkerVerdict{} h.walks = map[string]seenWalk{} h.orphans = map[string]time.Time{} h.asking = map[string]bool{} @@ -860,9 +864,8 @@ func verdictOfState(s string) string { // PullClosed takes a pull request closed unmerged: its open deliveries are stopped. // -// **And what its checks built for its merge goes** (novox/hq ADR 0350 §5): nothing will take it now. Asked of the -// controller once the deliveries are settled, outside the lock; a refusal is said, and the build seat's holders let -// go of it within the week anyway. +// What its checks built is no longer asked to be let go of here (issue 1318): the controller's forget-checks goes with +// the merge gate's checks (ADR 0503 §3), and the build seat's holders let go of it within the week. func (h *Holder) PullClosed(p PullEvent) { h.mu.Lock() h.forgetUnchecked(p.Owner, p.Repo, p.Number) @@ -899,15 +902,6 @@ func (h *Holder) PullClosed(p PullEvent) { h.pullUpdated(*over) } h.mu.Unlock() - if h.Controller == nil || p.Owner == "" || p.Repo == "" || p.Number <= 0 { - return - } - if err := h.Controller.ForgetChecks(p.Owner, p.Repo, p.Number); err != nil { - h.mu.Lock() - h.refuse(fmt.Errorf("%s#%d closed, and its check builds stay until the week's sweep: %w", p.repository(), - p.Number, err)) - h.mu.Unlock() - } } // CheckedEvent is the controller's `checked`. @@ -1959,7 +1953,7 @@ func standingVerdict(p OpenPull, now time.Time) *Verdict { if p.Checks == nil { return nil } - contexts := slices.Clone(mergeChecks) + contexts := []string{"mesh/merge-gate", "mesh/repo-check"} // the controller's; mesh/check is taken in askChecks for _, c := range p.Required { if !slices.Contains(contexts, c) { contexts = append(contexts, c) @@ -1995,7 +1989,8 @@ func (h *Holder) askChecks(now time.Time) func() { } w := want{id: d.ID, head: d.Commit, number: d.Number, member: MemberOf(d)} w.owner, w.repo, _ = strings.Cut(d.Repository, "/") - if !askedCheck(d) { + // A base that requires mesh/check is the checker's: nothing is asked of the controller (issue 1302). + if !askedCheck(d) && !d.judgedByCheck() { if tried, was := h.askTried[d.ID]; was && now.Sub(tried) < time.Minute { h.lookAt(tried.Add(time.Minute), "a proposed delivery's check that could not be asked") } else { @@ -2014,14 +2009,30 @@ func (h *Holder) askChecks(now time.Time) func() { open, err := h.Forge.OpenPulls() for _, w := range wants { var standing *Verdict + oneStatus := false if err == nil { for _, p := range open { if p.Number == w.number && strings.EqualFold(p.Owner, w.owner) && strings.EqualFold(p.Repo, w.repo) && sameCommit(p.HeadSHA, w.head) { + if slices.Contains(p.Required, ctxCheck) { + // Its base requires mesh/check (issue 1302): its verdict is that status, when it stands, and + // nothing is asked of the controller. + oneStatus = true + h.mu.Lock() + h.noteRequired(h.deliveries[w.id], p.Required) + if c := p.Checks[ctxCheck]; c.State != "" { + h.takeCheckStatus(w.owner+"/"+w.repo, w.head, c.State, c.Description) + } + h.mu.Unlock() + continue + } standing = standingVerdict(p, h.Now()) } } } + if oneStatus { + continue + } if standing != nil { h.mu.Lock() if d := h.deliveries[w.id]; d != nil && d.State == Proposed && d.Check == nil { @@ -2169,14 +2180,20 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func( allReady = false } } - again := g.Check != nil && (!sameMembers(g.Check.Commits, commits) || g.Check.OutOfDate != "" && rejudgedSince(members, g.Check.OutOfDateAt)) + // A group judged by mesh/check (issue 1302, ADR 0447 §2): its verdict is the checker's one verdict at its members' + // heads, read, not asked; nothing below asks a composed check or owes a member's mesh/merge-gate. + oneStatus := oneStatusGroup(members) + if oneStatus { + h.composeFromChecker(g, members, now) + } + again := !oneStatus && g.Check != nil && (!sameMembers(g.Check.Commits, commits) || g.Check.OutOfDate != "" && rejudgedSince(members, g.Check.OutOfDateAt)) // A closed group's member whose merge the tool refused, after the composed check answered, is judged again at once // (novox/hq ADR 0418 §4, issue 1082): the refusal is a sibling's merge having moved what the verdict stands on, and // nothing else asks the composed check again. Not while a check is on its way (its verdict is empty then). // **A refusal that names the member's OWN pending status** (mesh/repo-check, with the gate not pending: the composed // check set it) is not that: the composed check sets no repository check, so asking it again changes nothing. The // member's own check is asked, once for the head (issue 1049); its verdict sets the status. - if !again && g.Closed && g.Check != nil && g.Check.Verdict != "" { + if !oneStatus && !again && g.Closed && g.Check != nil && g.Check.Verdict != "" { for _, d := range members { if !refusedAfter(d, g.Check.At) { continue @@ -2193,7 +2210,7 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func( break } } - if allReady && (g.Check == nil && !g.Closed || again) { + if !oneStatus && allReady && (g.Check == nil && !g.Closed || again) { ms := membersOfAsk(members) id := g.ID asks = append(asks, func() { @@ -2262,7 +2279,7 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func( } // Each member's mesh/merge-gate is the composed check's (ADR 0403 §1, §6): pending, saying why, while no composed // verdict stands at these heads. The verdict itself sets it, success or failure, in the forge module. - if why := composedPending(g, members); why != "" { + if why := composedPending(g, members); why != "" && !oneStatus { if g.SaidGate == nil { g.SaidGate = map[string]string{} } @@ -2820,8 +2837,8 @@ func ownStatusPending(answer string) bool { // its own. Under the lock; the ask is made outside it. Nil when it was asked already for this head. func (h *Holder) askOwnAfterRefusal(d *Delivery) func() { r := d.Ready - if !r.OwnAskedAt.IsZero() { - return nil + if !r.OwnAskedAt.IsZero() || d.judgedByCheck() { + return nil // asked already; or the checker's, who checks it again on its own (issue 1302) } r.OwnAskedAt = h.Now() h.keep(d) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go new file mode 100644 index 00000000..bf93b7db --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go @@ -0,0 +1,12 @@ +package main + +import "git.novox.be/novox/mesh-sdk/go/announce" + +// Kept is every state this module keeps on the bus and whether other modules may read it (novox/hq ADR 0495 clause 2; +// issue 1790): the module says it itself, from its code, before it serves, and a reader is granted what it said +// (clause 3). A state is readable when another module's manifest reads it (keeps_test.go names the readers). keeps_test.go holds the list to the +// manifest's `state`, and keeps_integration_test.go that the module announces it on a bus. +var Kept = []announce.State{{Name: "asks", Readable: true}, {Name: "deliveries"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "pulls", Readable: true}, {Name: "source", Readable: true}} + +// announceKept says Kept through keeps, which is stdio.Keeps. +func announceKept(keeps func(...announce.State) error) error { return keeps(Kept...) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps_integration_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps_integration_test.go new file mode 100644 index 00000000..6269a4c7 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps_integration_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package main + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/announcetest" + "git.novox.be/novox/mesh-sdk/go/process" +) + +// 1790/S (ADR 0495 clause 5): started against a bus of the test's own, the module announces on it every state it keeps, +// readable as other modules' manifests need. +func TestTheModuleAnnouncesEveryStateItKeepsOnTheBus(t *testing.T) { + announcetest.Check(t, "mesh-delivery-gate", Kept, func(url string) func() { + conn, err := process.Connect(process.Account{URL: url, Module: "mesh-delivery-gate", Node: "ace", User: "u", Password: "p"}) + if err != nil { + t.Fatal(err) + } + if err := announceKept(func(s ...announce.State) error { return conn.Keeps(s...) }); err != nil { + t.Fatal(err) + } + return conn.Close + }) +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps_test.go new file mode 100644 index 00000000..687144b8 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" +) + +// readers is the gate's own declaration of who reads its states (ADR 0495 clause 3: a read is granted only when the owner +// announced the state readable), kept here and not read from other repositories' manifests (clause 1). Each line names +// the modules of mesh-delivery whose manifest reads the state, as of R4e. +var readers = map[string][]string{ + "asks": {"mesh-delivery-teller", "mesh-delivery-view"}, + "merges": {"mesh-delivery-forge-bridge"}, + "pulls": {"build-agent"}, // in mesh-controller + "owed": {"mesh-delivery-forge-bridge"}, + "source": {"mesh-delivery-indexer", "mesh-delivery-checker"}, +} + +// 1790/S: the states the module announces are the states its manifest declares, no more, no fewer, and the readable +// ones are the states other modules' manifests read. +func TestTheStatesTheModuleAnnouncesAreTheStatesItsManifestDeclares(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + State []json.RawMessage `json:"state"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + declared := []string{} + for _, s := range m.State { + var name string + if json.Unmarshal(s, &name) != nil { + var o struct{ Name string } + if err := json.Unmarshal(s, &o); err != nil { + t.Fatal(err) + } + name = o.Name + } + declared = append(declared, name) + } + announced := []string{} + for _, s := range Kept { + announced = append(announced, s.Name) + } + slices.Sort(declared) + slices.Sort(announced) + if !slices.Equal(declared, announced) { + t.Errorf("announces %v, the manifest declares %v", announced, declared) + } + for _, s := range Kept { + if want := len(readers[s.Name]) > 0; s.Readable != want { + t.Errorf("state %s readable = %v, but its readers are %v", s.Name, s.Readable, readers[s.Name]) + } + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/lostcheck.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/lostcheck.go index 07725fa8..bef8e0d7 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/lostcheck.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/lostcheck.go @@ -30,8 +30,8 @@ func (h *Holder) lostCheckBound(repository string) time.Duration { // awaitedSince is when a proposed delivery's check was asked, when it waits for one this owner asked: no verdict yet, // or a verdict out of date with its check against the trunk's head asked. False for a delivery waiting for none. func awaitedSince(d *Delivery) (time.Time, bool) { - if d.State != Proposed || d.MergedAs != "" || d.Number == 0 { - return time.Time{}, false + if d.State != Proposed || d.MergedAs != "" || d.Number == 0 || d.judgedByCheck() { + return time.Time{}, false // a head on mesh/check asks the controller nothing (issue 1302) } switch { case d.Check == nil && askedCheck(d): diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index e5ef06db..5f12ef4d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -77,6 +77,10 @@ func main() { h.init() l := &listening{now: "not yet: starting"} go run(h, l, occupancy) + if err := announceKept(stdio.Keeps); err != nil { + logf("%v", err) + os.Exit(1) + } if err := stdio.Serve("", tools(h, l)); err != nil { logf("%v", err) os.Exit(1) @@ -115,6 +119,25 @@ func run(h *Holder, l *listening, occupancy *busOccupancy) { time.Sleep(wait) } }() + // The checker's verdicts (issue 1302, ADR 0447 §2): the Approve on mesh/check is asked only on its verdict at exactly the + // heads an ask covers, and a group's verdict is that one; each change asks for a look. + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.State(checkerVerdicts).Watch("", func(c stdio.StateChange) error { + if c.Op == "delete" { + h.CheckerVerdictChanged(c.Key, nil) + } else { + h.CheckerVerdictChanged(c.Key, c.Value) + } + return nil + }) + if err == nil { + return + } + logf("[mesh-delivery] not hearing the checker's verdicts yet (%v); asking again in %s", err, wait) + time.Sleep(wait) + } + }() logf("[mesh-delivery] listening: %s", strings.Join(followed, ", ")) } @@ -313,7 +336,8 @@ func tools(h *Holder, l *listening) []stdio.Tool { {Name: seat + "groups", Description: "Every delivery group: its members, its composed check and its state, derived from its members. " + "A group has no order: it is merged in one pass and delivered by one plan, whose tiers come from the module " + - "graph (novox/hq ADR 0418). composed_gate says its members' gate is the composed check's (ADR 0403).", + "graph (novox/hq ADR 0418). On mesh/check its check is the checker's one verdict at every member's head (ADR 0447); " + + "composed_gate says its members' gate is the controller's composed check's (ADR 0403), for a base still on it.", Input: map[string]any{"type": "object", "properties": map[string]any{ "all": map[string]any{"type": "string", "enum": []string{"true", "false"}}}}, Run: func(a map[string]any) (any, error) { return h.Groups(boolArg(a, "all")), nil }}, @@ -330,7 +354,7 @@ func tools(h *Holder, l *listening) []stdio.Tool { }}, {Name: seat + "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each of the commit's " + - "statuses (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description and when it " + + "statuses (mesh/check, mesh/delivery, …; hq's mesh/repo-check) with its state, description and when it " + "was set; the merge check's full verdict as the controller said it — each layer's summary, the machine " + "that ran it, when, its build id and its report; and whether the branch's protection would let it " + "merge, every required status being success.", diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go index 4a9c41bc..30f76f7c 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go @@ -83,16 +83,17 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { if !reflect.DeepEqual(buckets, []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source"}) { t.Fatalf("state %v", buckets) } - // What it reads: the carry from today's module's three buckets, the teller's answers, the seat's holder and the - // machines' haves (ADR 0450 §6 and its insight of 2026-10-20). + // What it reads: the carry from today's module's three buckets, the teller's answers, the checker's verdicts (issue + // 1302), the seat's holder and the machines' haves (ADR 0450 §6 and its insight of 2026-10-20). if !reflect.DeepEqual(m.Reads, []string{"mesh-delivery.deliveries", "mesh-delivery.groups", "mesh-delivery.pulls", - "mesh-delivery-teller.answers", "mesh-controller.assignments", "node-engine.have"}) { + "mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts", "mesh-controller.assignments", "node-engine.have"}) { t.Fatalf("reads %v", m.Reads) } - // It calls exactly the controller's seven verbs (ADR 0450 §7) and reads the forge with three tools; the forge's + // It calls exactly the controller's seven verbs (ADR 0450 §7; queue since issue 1274, forget-checks no more since issue 1318) and reads the forge with + // three tools; the forge's // writes are the bridge's (ADR 0437 §8). It uses no operator-channel: the teller asks (ADR 0437 §6). want := []string{} - for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "forget-checks", "queue", "plans"} { + for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "queue", "plans"} { want = append(want, "seat:"+ControllerSeat+"."+v) } want = append(want, "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls") diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go index f025d9a9..5eb6cef3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go @@ -54,6 +54,12 @@ type Delivery struct { Plan *DeliveryPlan `json:"plan,omitempty"` // Check is its own check's verdict. Check *Verdict `json:"check,omitempty"` + // Required are the statuses its base requires, as the forge last said them (gitea_open_pulls): mesh/check makes it + // the checker's (issue 1302). Empty until the forge said them. + Required []string `json:"required,omitempty"` + // CheckState is its head's mesh/check as the forge last said it (pending, success, failure, error): a pending one + // after a pass is the checker judging it again, and no Approve is asked meanwhile (issue 1302). + CheckState string `json:"check_state,omitempty"` // CheckAsk is this owner's own ask of its check, when the forge's announcement did not bring one: a head // adopted while it already waited, or one whose announcement the controller missed. CheckAsk *CheckAsk `json:"check_ask,omitempty"` @@ -179,6 +185,9 @@ type Verdict struct { Trunk string `json:"trunk,omitempty"` Beside map[string]string `json:"beside,omitempty"` Took string `json:"took,omitempty"` + // Status is the forge status it was read from when it is not the controller's verdict: mesh/check, the checker's + // (novox/hq ADR 0447, issue 1302). Empty for the controller's `checked`. + Status string `json:"status,omitempty"` } // Judged is what a verdict judged, in words: "head 1a2b3c4d5e6f merged into trunk 7a8b9c0d1e2f" (ADR 0323 §3); @@ -1034,7 +1043,8 @@ type Group struct { Updated time.Time `json:"updated"` // Check is its composed check: asked of the controller for these member commits, and its verdict. Check *GroupCheck `json:"check,omitempty"` - // SaidGate is, by member id, why the member's mesh/merge-gate was last said pending: written again only when it + // SaidGate is, by member id, why the member's mesh/merge-gate was last said pending (a group the controller judges; + // a group on mesh/check is owed none, issue 1302): written again only when it // changes (novox/hq ADR 0403 §1, §6, issue 978). Cleared once a composed verdict stands at the heads, which the // forge module then sets as every member's gate. SaidGate map[string]string `json:"said_gate,omitempty"` diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go new file mode 100644 index 00000000..35e7ba14 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go @@ -0,0 +1,289 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "sort" + "strings" + "time" + + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// **One required status** (novox/hq ADR 0447, issue 1302; ADR 0503 §3, §5). +// +// Main requires `mesh/check` everywhere but hq: the checker's verdict on a pull request's head merged onto its trunk's +// head, written by its forge bridge. On such a base this owner reads that one status, and nothing of the controller: +// +// - the verdict of a head is its mesh/check, heard as the forge's status event (its words name the head and the trunk +// it judged: "pass on | …") or read from the forge's open pull requests; it is kept as the +// delivery's Check, marked as mesh/check's, and moves the delivery as the controller's verdict did; +// - the Approve is asked only when the checker's verdict for exactly the heads the ask covers passed (its verdicts +// bucket, keyed by the heads' hash, ADR 0447 §2): a member's pass from a check that judged it alone, before a sibling +// joined, is not the group's; +// - a group's verdict is that one verdict (no composed check is asked, and no member is owed a mesh/merge-gate); a +// member's mesh/check that is no longer a success withdraws the group's ask (ADR 0441 §1, withdrawForStatus); +// - no check is asked of the controller: the checker checks every head on every move, and the trunk's move is its to +// say on mesh/check (ADR 0447 §4). The controller is asked only for a base that requires mesh/repo-check without +// mesh/check (hq), and only what it was asked before (the lead's decision for issue 1302 in R4e). +// +// A base whose required statuses are not known yet is read as before: the forge's open pull requests say them at the next +// look, and a mesh/check heard on the head says it too. + +// ctxCheck is the one status main requires (ADR 0447 §1). +const ctxCheck = "mesh/check" + +// checkerVerdicts is the checker's verdicts bucket as this module reads it (its manifest's reads). +const checkerVerdicts = "mesh-delivery-checker.verdicts" + +// The states of a checker's verdict (mesh-sdk go/delivery VerdictRunning, VerdictPass, VerdictFail; this module's mesh-sdk +// pin is older than them). +const ( + checkerRunning = "running" + checkerPass = "pass" +) + +// checkerVerdict is a verdict of the checker as its verdicts bucket keeps it (mesh-sdk go/delivery Verdict, read here by +// the fields the gate needs): the group, the hash of the heads it judged, each repository's trunk head and its state. +type checkerVerdict struct { + Group string `json:"group"` + Heads string `json:"heads"` + Trunk map[string]string `json:"trunk,omitempty"` + State string `json:"state"` + Failed struct { + Test string `json:"test,omitempty"` + Machine string `json:"machine,omitempty"` + Words string `json:"words,omitempty"` + } `json:"failed,omitzero"` +} + +// CheckerVerdictChanged takes a key of the checker's verdicts bucket (`.`) put or deleted (an empty +// value): the view of the checker's verdicts the Approve and a group's verdict are read from. A value that is no verdict +// is said and dropped. +func (h *Holder) CheckerVerdictChanged(key string, value []byte) { + h.mu.Lock() + defer h.mu.Unlock() + if h.checker == nil { + h.checker = map[string]checkerVerdict{} + } + hash := key + if i := strings.LastIndex(key, "."); i >= 0 { + hash = key[i+1:] + } + if len(value) == 0 || string(value) == "null" { + delete(h.checker, hash) + h.Poke() + return + } + var v checkerVerdict + if err := json.Unmarshal(value, &v); err != nil || v.State == "" { + h.Logf("[mesh-delivery] the checker's verdict %s cannot be read (%v); it is not taken", key, err) + return + } + if v.Heads == "" { + v.Heads = hash + } + h.checker[v.Heads] = v + h.Poke() +} + +// checkWords are the head and the trunk mesh/check's line names: " on " (ADR 0447 §3). +var checkWords = regexp.MustCompile(`\b([0-9a-f]{12}) on ([0-9a-f]{12})\b`) + +// checkLine is the head and the trunk commit a mesh/check line names, twelve characters each; empty when it names none. +func checkLine(desc string) (head, trunk string) { + m := checkWords.FindStringSubmatch(strings.ToLower(desc)) + if m == nil { + return "", "" + } + return m[1], m[2] +} + +// verdictOfCheck is a delivery's verdict from its head's mesh/check: its state as a verdict, its line as the summary, and +// the head and trunk it names. +func verdictOfCheck(state, desc string, now time.Time) *Verdict { + head, trunk := checkLine(desc) + return &Verdict{At: now, Gate: verdictOfState(state), Summary: firstOf(desc, "mesh/check "+state), Head: head, Trunk: trunk, + Status: ctxCheck} +} + +// judgedByCheck is whether the delivery's base requires mesh/check, as the forge said it, or its verdict is mesh/check's. +func (d *Delivery) judgedByCheck() bool { + return slices.Contains(d.Required, ctxCheck) || (d.Check != nil && d.Check.Status == ctxCheck) +} + +// noteRequired keeps what the forge said the delivery's base requires. Under the lock. +func (h *Holder) noteRequired(d *Delivery, required []string) { + if d == nil || required == nil || slices.Equal(d.Required, required) { + return + } + d.Required = slices.Clone(required) + h.keep(d) +} + +// takeCheckStatus takes a head's mesh/check as its delivery's verdict, as the controller's `checked` was taken: a +// proposed delivery moves on it, a rejected one judged again against another trunk or now passing is proposed again +// first. A pending mesh/check is no verdict and changes nothing here (its ask is withdrawn by withdrawForStatus). The same +// verdict heard twice changes nothing. Under the lock. +func (h *Holder) takeCheckStatus(repository, sha, state, desc string) { + d := h.deliveries[IDOf(strings.ToLower(repository), sha)] + if d == nil || state == "" { + return // its announcement makes it; the forge's open pull requests bring the verdict then + } + if d.CheckState != state { + d.CheckState = state + h.keep(d) + } + if state == "pending" { + return + } + if !slices.Contains(d.Required, ctxCheck) { + d.Required = append(d.Required, ctxCheck) + } + now := h.Now() + v := verdictOfCheck(state, desc, now) + if c := d.Check; c != nil && c.Status == ctxCheck && c.Gate == v.Gate && c.Trunk == v.Trunk && c.Summary == v.Summary { + return + } + if d.State == Rejected && d.Check != nil && (v.Passes() || v.Trunk != "" && !sameCommit(d.Check.Trunk, v.Trunk)) { + t, err := Apply(d, EvRecheck, true, Facts{Now: now, By: byOwner, Why: fmt.Sprintf("its mesh/check judged it again: %s", + oneLine(clip(desc, 120)))}) + if err != nil { + h.refuse(err) + return + } + h.moved(d, []Transition{t}) + } + if d.State != Proposed { + h.keep(d) + return // past its verdict: the merge reads the forge's status itself + } + d.Check, d.AskedAgain, d.CheckAsk = v, nil, nil + h.mergeAgainNow(d) + if why := d.outOfDate(); why != "" { + h.Logf("[mesh-delivery] %s: its mesh/check is %s; the checker checks it again", d.ID, why) + } + h.Logf("[mesh-delivery] %s: its mesh/check is %s — %s", d.ID, state, oneLine(clip(desc, 120))) + h.settleAndKeep(d) +} + +// headsOf are the heads the checker judges together for an ask: the members the ask covers. +func headsOf(members []*Delivery) []sdk.Head { + out := make([]sdk.Head, 0, len(members)) + for _, m := range members { + out = append(out, sdk.Head{Repository: strings.ToLower(m.Repository), Number: m.Number, Head: strings.ToLower(m.Commit)}) + } + return out +} + +// checkerAt is the checker's verdict for exactly these members' heads, and their hash. +func (h *Holder) checkerAt(members []*Delivery) (checkerVerdict, string, bool) { + hash, err := sdk.HeadsHash(headsOf(members)) + if err != nil { + return checkerVerdict{}, "", false + } + v, ok := h.checker[hash] + return v, hash, ok +} + +// checkerWaits is why the Approve of a delivery on mesh/check is not asked yet: the checker's verdict for the heads the ask +// covers is not held, or did not pass. Empty when it passed, for a delivery not judged by mesh/check, and once an ask +// covers the head (the merge then reads the forge's statuses itself). Under the lock. +func (h *Holder) checkerWaits(d *Delivery) string { + if !d.judgedByCheck() || h.covering(d) != nil { + return "" + } + members := h.askMembers(d) + for _, m := range members { + if m.CheckState != "" && m.CheckState != "success" { + return fmt.Sprintf("the mesh/check of %s is %s at its head %s", m.ID, m.CheckState, shortOf(m.Commit, 12)) + } + } + v, hash, ok := h.checkerAt(members) + what := "its head" + if len(members) > 1 { + what = fmt.Sprintf("its group's %d heads", len(members)) + } + switch { + case !ok: + return fmt.Sprintf("the checker's verdict at %s (heads %s) is not held yet", what, hash) + case v.State == checkerRunning: + return fmt.Sprintf("the checker's verdict at %s (heads %s) is running", what, hash) + case v.State != checkerPass: + return fmt.Sprintf("the checker's verdict at %s (heads %s) is %s", what, hash, v.State) + } + return "" +} + +// oneStatusGroup is whether a group is judged by mesh/check: one of its members is. +func oneStatusGroup(members []*Delivery) bool { + return slices.ContainsFunc(members, (*Delivery).judgedByCheck) +} + +// composeFromChecker makes a group's verdict on mesh/check from the checker's one verdict at its members' heads (ADR 0447 +// §2): no composed check is asked. It is kept in the group's check as a composed verdict was, so the group's state, its +// Approve and its merges read it unchanged: running or not held is no verdict yet, a pass or a failure is the group's, and +// a member whose trunk moved past what it judged puts it out of date. Under the lock. +func (h *Holder) composeFromChecker(g *Group, members []*Delivery, now time.Time) { + live := make([]*Delivery, 0, len(members)) + for _, d := range members { + if d.MergedAs == "" && !d.State.Final() { + live = append(live, d) + } + } + if len(live) == 0 || (g.Closed && g.Check != nil && g.Check.Verdict != "") { + return // every member on the trunk, or a closed group: the verdict its Approve was asked on stands + } + v, hash, ok := h.checkerAt(live) + c := &GroupCheck{Asked: "mesh/check " + hash, At: now, Commits: commitsOf(members)} + if g.Check != nil && g.Check.Asked == c.Asked && sameMembers(g.Check.Commits, c.Commits) { + c.At = g.Check.At + } + if ok && v.State != checkerRunning { + c.Verdict = v.State + c.Summary = "the checker's verdict at the members' heads (" + hash + ")" + if w := firstOf(v.Failed.Test, v.Failed.Machine, v.Failed.Words); w != "" { + c.Summary += ": " + w + } + c.Trunks = map[string]string{} + ids := make([]string, 0, len(live)) + for _, d := range live { + ids = append(ids, d.ID) + } + sort.Strings(ids) + for _, id := range ids { + d := h.deliveries[id] + judged := v.Trunk[strings.ToLower(d.Repository)] + if judged == "" { + continue + } + c.Trunks[d.ID] = judged + if c.OutOfDate == "" && d.TrunkNow != "" && !sameCommit(judged, d.TrunkNow) { + c.OutOfDate = fmt.Sprintf("the checker's verdict is out of date: %s was judged on trunk %s, the trunk is now %s", + d.ID, shortOf(judged, 12), shortOf(d.TrunkNow, 12)) + c.OutOfDateAt = now + } + } + } + if g.Check != nil && g.Check.Asked == c.Asked && g.Check.Verdict == c.Verdict && g.Check.OutOfDate == c.OutOfDate && + sameMembers(g.Check.Commits, c.Commits) { + return + } + g.Check, g.Updated = c, now + h.keepGroup(g) + if c.Verdict != "" { + for _, d := range members { + h.mergeAgainNow(d) + } + h.Logf("[mesh-delivery] group %s: %s — %s", g.ID, c.Verdict, c.Summary) + } +} + +// checkerRefusal is why a delivery on mesh/check is asked nothing of the controller: the checker checks it. +func checkerRefusal(id string) error { + return fmt.Errorf("%s is judged by mesh/check, the checker's: the checker checks its head again on every move of the head, "+ + "its trunk or its group, and nothing is asked of it here (novox/hq ADR 0447). Read where its check stands with the "+ + "checker's checker_pull tool", id) +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_bus_test.go new file mode 100644 index 00000000..f81cd9c8 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_bus_test.go @@ -0,0 +1,82 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// **Issue 1302's acceptance over a real bus** (ADR 0447, 1302-S10): a pull request into a main that requires mesh/check goes +// ready on its mesh/check alone — the forge's status event and the checker's verdict in its verdicts bucket — has its Approve +// asked, and on the Approve its merge is handed to the bridge; nothing is asked of the controller (no delivery-check, no +// forget-checks) and no controller verdict is heard at all. +func TestADeliveryOnMeshCheckGoesReadyAskedAndMergedOverARealBus(t *testing.T) { + b := newTestBus(t) + ctx := context.Background() + rt := b.start(t) + evctx, cancel := context.WithCancel(ctx) + defer cancel() + rt.deliverEvents(evctx) + rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool { + rt.mu.Lock() + defer rt.mu.Unlock() + return len(rt.subs) >= len(followed) + }) + rt.untilHolding() + + const head = "2222222222222222222222222222222222222222" + const trunk = "3333333333333333333333333333333333333333" + rt.mu.Lock() + rt.openPulls = []OpenPull{{Owner: "novox", Repo: "app", Number: 8, Base: "main", HeadSHA: head, Required: []string{ctxCheck}, + Statuses: []string{}, Checks: map[string]HeadCheck{}}} + rt.mu.Unlock() + announced := pr("novox/app", 8, head, "fix/y", "src/b.go") + announced.Body = "Reviewed-by: lead@shanks at " + head + b.publish(t, "mesh.mod.gitea.event.pull.updated", announced) + + // The checker's verdict at the head, then the forge's word that its bridge set mesh/check. + hash, err := sdk.HeadsHash([]sdk.Head{{Repository: "novox/app", Number: 8, Head: head}}) + if err != nil { + t.Fatal(err) + } + verdict, _ := json.Marshal(map[string]any{"v": 1, "group": "fix/y", "heads": hash, "trunk": map[string]string{"novox/app": trunk}, + "state": "pass"}) + if _, err := b.kvs[checkerVerdicts].Put(ctx, "fix/y."+hash, verdict); err != nil { + t.Fatal(err) + } + line := "pass " + head[:12] + " on " + trunk[:12] + " | unit 3 run, 0 kept" + rt.mu.Lock() + rt.openPulls[0].Statuses = []string{ctxCheck} + rt.openPulls[0].Checks = map[string]HeadCheck{ctxCheck: {State: "success", Description: line}} + rt.mu.Unlock() + set := b.publish(t, "mesh.mod.gitea.event.forge.status", map[string]any{"owner": "novox", "repo": "app", "sha": head, + "context": ctxCheck, "state": "success", "description": line}) + + var key string + rt.until("the ask to be written on mesh/check alone", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" }) + t.Logf("the ask was written %s after mesh/check was set", time.Since(set)) + b.answer(t, key, OptionApprove) + var handed sdk.Merge + rt.until("the merge to be handed to the bridge", 10*time.Second, func() bool { handed = mergeAt(b, "fix/y"); return len(handed.Pulls) > 0 }) + if len(handed.Pulls) != 1 || handed.Pulls[0].Head.Head != head || handed.Pulls[0].State != sdk.MergeMerging { + t.Fatalf("the merge handed is %+v", handed) + } + for _, verb := range []string{"delivery-check", "forget-checks"} { + if n := len(rt.asksOf("seat:" + ControllerSeat + "." + verb)); n != 0 { + t.Fatalf("the controller was asked %s %d times on a mesh/check base", verb, n) + } + } + // No mesh/merge-gate is owed on the head. + keys, _ := b.kvs["owed"].Keys(ctx) + for _, k := range keys { + if strings.Contains(k, "merge-gate") { + t.Fatalf("a mesh/merge-gate was owed: %s", k) + } + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_test.go new file mode 100644 index 00000000..6bfb3c26 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The tests of issue 1302 (ADR 0447 §2, §4; ADR 0441 §1; ADR 0421 §1, §3; lead decisions L6 and the gate's part of +// 1318): on a base that requires mesh/check the gate reads that one status. A delivery is ready, its Approve asked and its +// merge handed on when mesh/check passed at its head and the checker's verdict covers every member's head as they are; no +// check is asked of the controller, no composed check is asked, and no mesh/merge-gate is owed. + +const ( + oneRepo, oneRepo2 = "novox/mesh-sdk", "novox/mesh-delivery" + oneHead, oneHead2 = "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" + oneTrunk = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + oneTrunk2 = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" +) + +// requiresCheck makes the forge say each pull request's base requires mesh/check alone, as gitea_open_pulls answers. +func requiresCheck(w *world, pulls ...PullEvent) { + w.forge.mu.Lock() + defer w.forge.mu.Unlock() + w.forge.open = nil + for _, p := range pulls { + w.forge.open = append(w.forge.open, OpenPull{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: "main", + HeadSHA: p.HeadSHA, Required: []string{ctxCheck}, Statuses: []string{}, Checks: map[string]HeadCheck{}}) + } +} + +// checkStatus is the forge's word that mesh/check was set on a head, with its line as the checker writes it. +func checkStatus(repository, head, state, trunk string) ForgeStatusEvent { + owner, repo, _ := strings.Cut(repository, "/") + words := map[string]string{"success": "pass", "pending": "checking", "failure": "failed: a test"}[state] + return ForgeStatusEvent{Owner: owner, Repo: repo, SHA: head, Context: ctxCheck, State: state, + Description: words + " " + head[:12] + " on " + trunk[:12] + " | unit 4 run, 0 kept"} +} + +// putCheckerVerdict puts the checker's verdict for these heads in the gate's view of the checker's verdicts bucket. +func putCheckerVerdict(w *world, state string, heads ...sdk.Head) { + w.t.Helper() + hash, err := sdk.HeadsHash(heads) + if err != nil { + w.t.Fatal(err) + } + trunk := map[string]string{} + for _, h := range heads { + trunk[h.Repository] = oneTrunk + } + value, _ := json.Marshal(map[string]any{"v": 1, "group": "g", "heads": hash, "trunk": trunk, "state": state}) + w.h.CheckerVerdictChanged("g."+hash, value) +} + +func headOf(repository string, number int, head string) sdk.Head { + return sdk.Head{Repository: repository, Number: number, Head: head} +} + +func mergeGateOwed(w *world) []string { + w.forge.mu.Lock() + defer w.forge.mu.Unlock() + var out []string + for k := range w.forge.statuses { + if strings.HasSuffix(k, " mesh/merge-gate") { + out = append(out, k) + } + } + return out +} + +// 1302 acceptance: the gate's table moves a lone delivery to ready on mesh/check alone, asks its Approve and merges it, +// with no check asked of the controller and no mesh/merge-gate written. +func TestALoneDeliveryGoesReadyAskedAndMergedOnMeshCheckAlone(t *testing.T) { + w := newWorld(t) + p := pr(oneRepo, 1, oneHead, "feat/one") + requiresCheck(w, p) + w.h.PullUpdated(p) + w.settleAll() + if got := w.ctl.checks; len(got) != 0 { + t.Fatalf("a check was asked of the controller for a mesh/check base: %v", got) + } + id := IDOf(oneRepo, oneHead) + if s := w.state(id); s != Proposed { + t.Fatalf("before mesh/check it is %s, want proposed", s) + } + putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead)) + w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk)) + w.settleAll() + if s := w.state(id); s == Proposed || s == Rejected { + t.Fatalf("after mesh/check passed it is %s (%s), want ready or on", s, w.logs()) + } + if d := w.delivery(id); d.Check == nil || d.Check.Status != ctxCheck || d.Check.Trunk != oneTrunk[:12] || d.Check.Head != oneHead[:12] { + t.Fatalf("the verdict is not mesh/check's: %+v", d.Check) + } + if w.op.count() != 1 { + t.Fatalf("its Approve was asked %d times, want once", w.op.count()) + } + if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-sdk#1") { + t.Fatalf("merges %v, want mesh-sdk#1", w.forge.merges) + } + if got := mergeGateOwed(w); len(got) != 0 { + t.Fatalf("mesh/merge-gate was written: %v", got) + } + if len(w.ctl.checks) != 0 { + t.Fatalf("a check was asked of the controller: %v", w.ctl.checks) + } +} + +// 1302 (lead: the verdict read must be the group's): mesh/check passing on a head is not enough while the checker's +// verdict for exactly the delivery's heads is not held: the Approve waits for it, and says so. +func TestTheApproveWaitsForTheCheckersVerdictAtTheDeliverysHeads(t *testing.T) { + w := newWorld(t) + p := pr(oneRepo, 1, oneHead, "feat/one") + requiresCheck(w, p) + w.h.PullUpdated(p) + w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk)) + w.settleAll() + if w.op.count() != 0 { + t.Fatalf("the Approve was asked with no checker verdict at the head") + } + if d := w.delivery(IDOf(oneRepo, oneHead)); d.Ready == nil || !strings.Contains(d.Ready.Waits, "checker") { + t.Fatalf("it does not say it waits for the checker's verdict: %+v", d.Ready) + } + putCheckerVerdict(w, "running", headOf(oneRepo, 1, oneHead)) + w.settleAll() + if w.op.count() != 0 { + t.Fatalf("the Approve was asked on a running verdict") + } + putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead)) + w.settleAll() + if w.op.count() != 1 { + t.Fatalf("the Approve was asked %d times after the checker's pass, want once", w.op.count()) + } +} + +// 1302 acceptance (ADR 0447 §2, ADR 0441 §1): a group on mesh/check is asked once when every member's mesh/check passed +// and the checker's verdict names every member's head; a member's mesh/check that changes withdraws the ask. No composed +// check is asked of the controller and no member is owed a mesh/merge-gate. +func TestAGroupOnMeshCheckIsAskedOnTheCheckersOneVerdictAndWithdrawnWhenAMemberChanges(t *testing.T) { + w := newWorld(t) + w.op.auto = false + a, b := pr(oneRepo, 1, oneHead, "feat/two"), pr(oneRepo2, 2, oneHead2, "feat/two") + requiresCheck(w, a, b) + w.h.PullUpdated(a) + w.h.PullUpdated(b) + w.h.wantForge() // as the event's handler does for a head not seen before (main.go) + w.settleAll() + // One member's verdict alone, of a check that judged it by itself: not the group's. + putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead)) + w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk)) + w.h.CommitStatus(checkStatus(oneRepo2, oneHead2, "success", oneTrunk)) + w.settleAll() + if w.op.count() != 0 { + t.Fatalf("the group was asked on a verdict that does not name every member's head") + } + putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead), headOf(oneRepo2, 2, oneHead2)) + w.settleAll() + if w.op.count() != 1 || len(w.op.open()) != 1 { + t.Fatalf("the group was asked %d times (open %v), want once", w.op.count(), w.op.open()) + } + if len(w.ctl.checks) != 0 { + t.Fatalf("a check was asked of the controller: %v", w.ctl.checks) + } + if got := mergeGateOwed(w); len(got) != 0 { + t.Fatalf("mesh/merge-gate was owed: %v", got) + } + w.h.CommitStatus(checkStatus(oneRepo2, oneHead2, "pending", oneTrunk2)) + w.settleAll() + if len(w.op.open()) != 0 { + t.Fatalf("the group's ask stands though a member's mesh/check changed: %v", w.op.open()) + } +} + +// 1302: a failing mesh/check rejects the delivery; nothing is asked. +func TestAFailingMeshCheckRejects(t *testing.T) { + w := newWorld(t) + p := pr(oneRepo, 1, oneHead, "feat/one") + requiresCheck(w, p) + w.h.PullUpdated(p) + w.h.CommitStatus(checkStatus(oneRepo, oneHead, "failure", oneTrunk)) + w.settleAll() + if s := w.state(IDOf(oneRepo, oneHead)); s != Rejected { + t.Fatalf("it is %s, want rejected", s) + } + if w.op.count() != 0 || len(w.ctl.checks) != 0 { + t.Fatalf("asked: operator %d, controller %v", w.op.count(), w.ctl.checks) + } +} + +// 1302 (lead's (2)): the recheck verb refuses a delivery on a mesh/check base, saying the checker checks again on every +// move and where to read it. +func TestRecheckRefusesAMeshCheckDelivery(t *testing.T) { + w := newWorld(t) + p := pr(oneRepo, 1, oneHead, "feat/one") + requiresCheck(w, p) + w.h.PullUpdated(p) + w.h.CommitStatus(checkStatus(oneRepo, oneHead, "failure", oneTrunk)) + w.settleAll() + _, err := w.h.Recheck(IDOf(oneRepo, oneHead), "try again", "tester") + if err == nil || !strings.Contains(err.Error(), "checker_pull") || !strings.Contains(err.Error(), "checker") { + t.Fatalf("recheck answered %v, want a refusal naming the checker and checker_pull", err) + } + if len(w.ctl.checks) != 0 { + t.Fatalf("a check was asked of the controller: %v", w.ctl.checks) + } +} + +// 1318 (the gate's part): a pull request closed unmerged asks the controller to forget nothing: forget-checks is not called. +func TestAClosedPullRequestAsksNoForgetChecks(t *testing.T) { + w := newWorld(t) + p := pr(oneRepo, 1, oneHead, "feat/one") + w.h.PullUpdated(p) + w.h.PullClosed(p) + if len(w.ctl.forgot) != 0 { + t.Fatalf("forget-checks was called: %v", w.ctl.forgot) + } +} + +// 1302: the mesh/check line gives the head and the trunk it judged. +func TestTheCheckLineGivesHeadAndTrunk(t *testing.T) { + head, trunk := checkLine("pass " + oneHead[:12] + " on " + oneTrunk[:12] + " | unit 4 run, 0 kept | group g of 2 pull requests") + if head != oneHead[:12] || trunk != oneTrunk[:12] { + t.Fatalf("head %q trunk %q", head, trunk) + } + if head, trunk = checkLine("failed: a test"); head != "" || trunk != "" { + t.Fatalf("a line that names no commits gave %q %q", head, trunk) + } +} + +// 1302 with issue 1274's lost checks: a head on mesh/check never awaits a controller's check, so the build queue is not +// read for it and nothing is asked again. +func TestAHeadOnMeshCheckAwaitsNoControllersCheck(t *testing.T) { + at := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) + d := &Delivery{State: Proposed, Number: 1, CheckAsk: &CheckAsk{ID: "check-1", At: at}} + if _, ok := awaitedSince(d); !ok { + t.Fatal("the controller's asked check is not awaited") + } + d.Required = []string{ctxCheck} + if _, ok := awaitedSince(d); ok { + t.Fatal("a head on mesh/check awaits a controller's check") + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go index c2af77b7..b416fa9c 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go @@ -6,7 +6,6 @@ import ( "fmt" "slices" "sort" - "strconv" "strings" stdio "git.novox.be/novox/mesh-sdk/go" @@ -65,8 +64,6 @@ type Controller interface { StopWithout(walk, why, by string, without []string) (StopAnswer, error) // Walks are the walks the controller keeps — one, given its id — and whether this seat has a holder on record. Walks(walk string) (bool, []Walk, error) - // ForgetChecks lets go of the builds a pull request's merge checks made for its merge (novox/hq ADR 0350 §5). - ForgetChecks(owner, repo string, number int) error // Queue is every ask the build queue holds, waiting or in flight (the controller's `queue`, novox/hq issue 455): what // tells a check still on its way from one the queue lost (issue 1274). A dead ask is not held. Queue() ([]QueuedAsk, error) @@ -290,11 +287,6 @@ func (c seatController) BackupsAgain(walk, why string) error { return err } -func (c seatController) ForgetChecks(owner, repo string, number int) error { - _, err := c.verb("forget-checks", map[string]any{"owner": owner, "repo": repo, "number": strconv.Itoa(number)}) - return err -} - func (c seatController) Stop(walk, why, by string) error { _, err := c.verb("delivery-stop", map[string]any{"plan": walk, "why": why, "by": by}) return err diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go index b68d1017..ca9c08b2 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go @@ -299,8 +299,8 @@ func (h *Holder) sayAfterLine(d *Delivery) func() { // second Approve. Nil when nothing needs asking. Under the lock; the ask runs outside it. func (h *Holder) askFresh(d *Delivery, now time.Time) func() { if d.State != Proposed || d.outOfDate() == "" || d.AskedAgain != nil || !d.Check.Passes() || d.Base != MergeBranch || - d.Number == 0 || d.TrunkNow == "" { - return nil + d.Number == 0 || d.TrunkNow == "" || d.judgedByCheck() { + return nil // a head on mesh/check is the checker's to judge again on its trunk's move (ADR 0447 §4) } // A group's member too (issue 1047, design 47: a move of the trunk asks every check again): the group's one Approve is // asked after its composed check, so a member ready before that has no Approve to carry; its check is asked at once @@ -383,6 +383,9 @@ func (r *ReadyRun) noteRefusal(d *Delivery, answer string) { // stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an // Approve waits for that check first. func (h *Holder) readyWaits(d *Delivery) string { + if d.judgedByCheck() && d.State == Proposed && d.outOfDate() != "" && h.covering(d) == nil { + return "its mesh/check is " + d.outOfDate() + ": the checker checks it again against the trunk's head" + } switch { case d.State == Ready: case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain != nil: @@ -402,6 +405,10 @@ func (h *Holder) readyWaits(d *Delivery) string { return w } } + // On mesh/check, the checker's verdict at exactly the heads the ask covers (issue 1302, ADR 0447 §2). + if w := h.checkerWaits(d); w != "" { + return w + } if d.Group == "" { return "" } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go index bbcbf204..77d4ed28 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go @@ -54,9 +54,10 @@ const outOfDatePrefix = "out of date:" // minute and the check is asked at once, so ten minutes is a fault, never a queue. const UncheckedAfter = 10 * time.Minute -// mergeChecks are the statuses that are the mesh's merge check (novox/hq ADR 0237): a base requiring either is -// one the mesh is meant to check. -var mergeChecks = []string{"mesh/merge-gate", "mesh/repo-check"} +// mergeChecks are the statuses that are the mesh's check: mesh/check, the one main requires (novox/hq ADR 0447, issue +// 1302), and the two it replaced, which hq's base (mesh/repo-check, ADR 0503 §5) and a reverted one still require. A base +// requiring any is one the mesh is meant to check; only those a base requires are waited on. +var mergeChecks = []string{ctxCheck, "mesh/merge-gate", "mesh/repo-check"} // OpenPull is an open pull request as the forge says it (the gitea module's gitea_open_pulls): its head, its base, // the contexts the base's protection requires, the contexts its head has a status of, in any state, and each of @@ -179,6 +180,15 @@ func (h *Holder) SeeOpenPulls() error { now := h.Now() seen := map[string]unchecked{} for _, p := range open { + if slices.Contains(p.Required, ctxCheck) { + // What the forge says of a head on a mesh/check base is its verdict (issue 1302): kept, and taken. + if d := h.deliveries[IDOf(p.Owner+"/"+p.Repo, p.HeadSHA)]; d != nil { + h.noteRequired(d, p.Required) + if c := p.Checks[ctxCheck]; c.State != "" { + h.takeCheckStatus(p.Owner+"/"+p.Repo, p.HeadSHA, c.State, c.Description) + } + } + } waiting, pending, _ := p.unanswered() if len(waiting) == 0 { continue diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go index 27e876c5..a4543b06 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go @@ -152,8 +152,9 @@ type GroupLine struct { ID string `json:"id"` State string `json:"state"` Why string `json:"why"` - // ComposedGate says the group's gate is the composed check's: each member's own pull request check runs the - // repository's check alone, and the composed verdict sets its mesh/merge-gate (novox/hq ADR 0403 §1, issue 978). + // ComposedGate says the group's gate is the controller's composed check's: each member's own pull request check runs + // the repository's check alone, and the composed verdict sets its mesh/merge-gate (novox/hq ADR 0403 §1, issue 978). + // On mesh/check the group's check is the checker's one verdict at every member's head (ADR 0447 §2, issue 1302). ComposedGate bool `json:"composed_gate"` Check string `json:"composed,omitempty"` Members []string `json:"members"` @@ -215,8 +216,8 @@ type StalledLine struct { State State `json:"state"` // Walk is the walk a line of the state Unheld is about (novox/hq issue 362). Walk string `json:"walk,omitempty"` - // Waiting are the merge checks a line of the state Unanswered waits on, each as it reads — "mesh/repo-check - // never set", "mesh/merge-gate pending since " — so the controller's words name them without reading + // Waiting are the merge checks a line of the state Unanswered waits on, each as it reads — "mesh/check + // never set", "mesh/check pending since " — so the controller's words name them without reading // Says (novox/hq issue 438). Waiting []string `json:"waiting,omitempty"` // Checks are the same merge checks as data — context, state, since when — so the controller says their times in @@ -303,6 +304,10 @@ func (h *Holder) Recheck(id, why, by string) (string, error) { h.mu.Unlock() return "", fmt.Errorf("no delivery %q", id) } + if d.judgedByCheck() { + h.mu.Unlock() + return "", checkerRefusal(id) // the lead's decision for issue 1302: the checker serves no ask + } if d.State == Proposed { // A proposed delivery's check is asked again when the build queue holds none of its head (issue 1274). h.mu.Unlock() @@ -670,6 +675,10 @@ func (h *Holder) Close(id, why string) (string, error) { // does not take spends nothing. Called under the lock; it unlocks. func (h *Holder) reask(d *Delivery, why string) (string, error) { id := d.ID + if d.judgedByCheck() { + h.mu.Unlock() + return "", checkerRefusal(id) + } trial := *d trial.Transitions = nil if _, err := Apply(&trial, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why}); err != nil { diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index b37ee2ea..6d836fc2 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-gate/go.sum b/modules/mesh-delivery-gate/go.sum index 3807e8df..db1ea18c 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,15 +1,9 @@ -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= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= 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/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= @@ -18,23 +12,15 @@ 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/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= -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/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= 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/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= 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/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index b9a06d0e..0ac8e281 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -46,7 +46,6 @@ "seat:mesh-controller.delivery-backups-again", "seat:mesh-controller.delivery-walks", "seat:mesh-controller.delivery-check", - "seat:mesh-controller.forget-checks", "seat:mesh-controller.queue", "seat:mesh-controller.plans", "gitea.gitea_commit_statuses", @@ -79,6 +78,7 @@ "mesh-delivery.groups", "mesh-delivery.pulls", "mesh-delivery-teller.answers", + "mesh-delivery-checker.verdicts", "mesh-controller.assignments", "node-engine.have" ], diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go index 7bd28b50..489d5d28 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go @@ -120,7 +120,7 @@ type Client struct { // Now dates the reports; nil is time.Now. Now func() time.Time // SocketUp wakes the client when the node-engine's write socket is made again (watchSocket); nil never does. - // A module whose write failed for want of the socket is filled again then (issue 1165), and nothing else is. + // A module whose write failed is filled again then (issue 1165, issue 1873), and nothing else is. SocketUp <-chan struct{} // watchBound watches a provider's bucket of entries for this machine's keys, once per run (consumers.go); set by @@ -150,7 +150,8 @@ type state struct { // of the consumers left out of one of its files. providing map[string]bool leftOut map[string]string - // unplaced is a module whose last fill failed for want of the node-engine's write socket (issue 1165). + // unplaced is a module whose last fill failed, for want of the node-engine's write socket (issue 1165) or by the + // engine's own refusal (issue 1873): filled again when the socket is made again. unplaced map[string]bool // 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. @@ -705,9 +706,10 @@ func (c *Client) reconcile(ctx context.Context, kvs map[string]jetstream.KeyValu err = errFill(a) } if err != nil { - if socketAbsent(err) { - s.unplaced[module] = true - } + // Filled again when a node-engine makes its socket again: one gone (issue 1165), or one that refused for a + // reason of its own, which a newer engine may write (issue 1873: "is a directory", what a container + // runtime left at the path, cleared by the engine that followed). + s.unplaced[module] = true failed = append(failed, fmt.Sprintf("%s: %v", f.Path, err)) continue } diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/socket_integration_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/socket_integration_test.go index 094a7e29..049b0be7 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/socket_integration_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/socket_integration_test.go @@ -106,3 +106,38 @@ func TestWatchSocketWakesWhenTheSocketIsMadeAndWhenItsDirectoryReturns(t *testin defer l.Close() heard("the directory and socket back") } + +// Issue 1873: a fill the node-engine refused for a reason of its own (on ace, "read …/superuser.secret: is a +// directory", what a container runtime had left there) is tried again when a node-engine makes its socket again, as a +// newer engine may write what the older could not. Before, only a fill that failed for want of the socket was, so +// ace's postgres stayed unplaced after the engine that cures it was delivered, and its old failure was said every 30 s. +func TestNatsAFillTheEngineFailedIsTriedWhenTheSocketReturns(t *testing.T) { + b := startBus(t) + heard := b.listen(t) + e := newEngine(t, map[string]string{dunstrc: "owned"}) + if err := os.MkdirAll(e.at(dunstrc), 0o755); err != nil { + t.Fatal(err) + } + stop := e.listen(t) + b.config(t, "dunst", "font-size", "13") + b.assign(t, "dunst", dunst(t)) + aClient(t, b, e, t.TempDir()) + + eventually(t, "the engine's failure is said", func() bool { + r, _ := heard.last("dunst") + return r.Status == StatusFailed && strings.Contains(r.Reason, "is a directory") + }) + // The node-engine replaces itself with one that clears what the runtime left. + stop() + if err := os.Remove(e.at(dunstrc)); err != nil { + t.Fatal(err) + } + e.listen(t) + eventually(t, "dunstrc is written by the new engine", func() bool { + return strings.Contains(e.read(t, dunstrc), "Monospace 13") + }) + eventually(t, "and the report says applied", func() bool { + r, _ := heard.last("dunst") + return r.Status == StatusApplied + }) +} diff --git a/modules/model-usage/package-lock.json b/modules/model-usage/package-lock.json new file mode 100644 index 00000000..9354cc90 --- /dev/null +++ b/modules/model-usage/package-lock.json @@ -0,0 +1,161 @@ +{ + "name": "@novox/module-model-usage", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@novox/module-model-usage", + "version": "0.1.0", + "dependencies": { + "pg": "^8" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/modules/mongodb/package-lock.json b/modules/mongodb/package-lock.json new file mode 100644 index 00000000..c1ad4488 --- /dev/null +++ b/modules/mongodb/package-lock.json @@ -0,0 +1,162 @@ +{ + "name": "@novox/module-mongodb", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@novox/module-mongodb", + "version": "0.1.0", + "dependencies": { + "mongodb": "^6.21.0" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.1.tgz", + "integrity": "sha512-D4CQtk9IwjCLAvCf1xvHEod3960q8XMUzfmdRU9qjUNlHnesGuTOvJcOTaPm9koZ1caUKA2aTbszZiAkcmSgmg==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/modules/mssql/package-lock.json b/modules/mssql/package-lock.json new file mode 100644 index 00000000..d3dadae6 --- /dev/null +++ b/modules/mssql/package-lock.json @@ -0,0 +1,944 @@ +{ + "name": "@novox/module-mssql", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@novox/module-mssql", + "version": "0.1.0", + "dependencies": { + "mssql": "^11.0.2" + } + }, + "node_modules/@azure-rest/core-client": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.9.0.tgz", + "integrity": "sha512-6933vNLqh06RR7rumnrq3UZIeBtRLeBHDPMrieDPIljiCaMMwt0nRw++nd3TOxiohtasNtm29rC+b/9ZVKCOqg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.24.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", + "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.7.0.tgz", + "integrity": "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-process": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", + "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.14.0.tgz", + "integrity": "sha512-8utCLdTZn14CiAe0HW4JbtzUMsc0mdDwnjUvStFMrR4IxQP8S2eL9zVAdplpfORVJGYmidZElpeXRYjJQ4UyUw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-process": "^1.0.0", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^6.0.0", + "open": "^11.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz", + "integrity": "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.2.tgz", + "integrity": "sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.1.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.26.0.tgz", + "integrity": "sha512-GYKRquzDZmZggCdStWy2bNJKnPui1upssSuznJgP+/h6Mb0ldkUkaGvaY6uSGGmZOm0CGiBQ3b5BiGy43BpXSg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.3" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.14.3", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.3.tgz", + "integrity": "sha512-tz3GvMrR9V/plb2lDdW8qaO8CPpfZevdExBKjltzzbAtyhK0eB2UIvji1aU+gkyHj3fxQ2ie+LNHyMXdmo8wOg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz", + "integrity": "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", + "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@js-joda/core": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", + "integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==", + "license": "BSD-3-Clause" + }, + "node_modules/@tediousjs/connection-string": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", + "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.25.tgz", + "integrity": "sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mssql": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/mssql/-/mssql-11.0.2.tgz", + "integrity": "sha512-x3Rdo3aR7/gQgpcVWA5EexMTd/BtHzdOkv94MTvV137UglFOZUJER6wRlq2Fl4R0F7JqBpDYyV4hX1kMTP0j7A==", + "license": "MIT", + "dependencies": { + "@tediousjs/connection-string": "^0.5.0", + "commander": "^11.0.0", + "debug": "^4.3.3", + "rfdc": "^1.3.0", + "tarn": "^3.0.2", + "tedious": "^18.2.1" + }, + "bin": { + "mssql": "bin/mssql" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "license": "MIT" + }, + "node_modules/open": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tarn": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.2.tgz", + "integrity": "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tedious": { + "version": "18.6.2", + "resolved": "https://registry.npmjs.org/tedious/-/tedious-18.6.2.tgz", + "integrity": "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.7.2", + "@azure/identity": "^4.2.1", + "@azure/keyvault-keys": "^4.4.0", + "@js-joda/core": "^5.6.1", + "@types/node": ">=18", + "bl": "^6.0.11", + "iconv-lite": "^0.6.3", + "js-md4": "^0.3.2", + "native-duplexpair": "^1.0.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/wsl-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/modules/pacman/README.md b/modules/pacman/README.md index 4ba4969a..fd400323 100644 --- a/modules/pacman/README.md +++ b/modules/pacman/README.md @@ -10,10 +10,10 @@ that seat (ADR 0207). (ADR 0207). - **`/etc/pacman.conf`, whole.** A block cannot be added to `[options]` by appending: anything added at the end of the file lands in the last repository's section. So the module owns the file: - - The repositories are the union of what the four machines had enabled on 2026-10-04: `core`, - `extra` and `multilib`. All four had all three. + - The repositories are the union of what the mesh's machines had enabled on 2026-10-04: `core`, + `extra` and `multilib`. All had all three. - The options are the distribution's defaults, plus `Color`, `ParallelDownloads = 5`, - `VerbosePkgLists`, and `DownloadUser = alpm` (pacman 7; the `alpm` user exists on all four). + `VerbosePkgLists`, and `DownloadUser = alpm` (pacman 7; the `alpm` user exists on every machine). - The manifest test runs `pacman-conf` on the rendered file and checks the repository list and the options as pacman reads them. It skips that check where `pacman-conf` is absent. - The host keeps the machine's previous file once, the first time it writes over it (ADR 0102). @@ -23,23 +23,44 @@ that seat (ADR 0207). list stays reflector's to write, not the mesh's. - **Cache cleaning.** The `pacman-contrib` package and `paccache.timer` running and enabled. Each week it keeps the last three versions of each package. -- **The weekly upgrade (ADR 0477 clause 5, issue 1574).** `mesh-pacman-weekly-upgrade.timer` and its - oneshot service, placed whole in `/etc/systemd/system`. The timer fires every Saturday at 04:00 in the - node's local time, the same moment on every node (no random delay), and is `Persistent=true`: a node that - was off or asleep then upgrades at its next start. The service runs `/usr/bin/pacman -Syu --noconfirm`, the - same arguments `pacman_upgrade` starts (`UpgradeArgs` in the code; a manifest test holds the two together). - No other moment upgrades but a hand `pacman_upgrade`, and the mesh restarts no machine for an upgrade: a new - kernel waits for the next restart. - - **The outcome is the module's health.** The timer's resource asks `pacman_upgrade_health`: unhealthy, - naming the exit and the last `error:` line of the journal, when the last weekly upgrade failed; healthy - while it runs, after it passed, and before it ever ran. The controller raises the module's condition for - the node from it. A failed service stays failed until - the next run, so the condition stands until an upgrade passes; a hand `pacman_upgrade` does not clear - it. A restart of the machine clears the failed state and with it the condition, and the next Saturday - runs again. A run that goes on longer than two hours is unhealthy too, naming when it started and the - last line of its journal: it is hung and holds pacman's database lock. The service has no start - timeout on purpose, because killing pacman in the middle of a transaction is worse; the hung run is - said, not killed. +- **The package view on the bus (ADR 0507).** A process of its own, the keeper (`pacman-tools keeper`, account + `pacman-keeper`, its own bus account), keeps the node's packages in the state `installed` and what an upgrade + would change in `updates`, written only on change: at its start, whenever pacman's database lock is released + (an inotify watch), when reflector rewrites the mirror list, and once a day. Each change of the packages is + `package.changed`. A list over the bus's cap on a value is not written: the key keeps its last value and + `.not-current` says the size and since when, which the health says too. `pacman_packages`, + `pacman_updates`, `pacman_installed` and `pacman_foreign` answer every node from these states, and what no + module declares from the controller's `declared-packages`, without asking a machine. +- **Upgrades as runs, one node at a time (ADR 0508).** Every full upgrade and every rollback is a run in the + state `runs` at `.`, written at each step and emitted as it: the package record taken and read back + (`records`, kept `record-days`), the DKMS gate before the transaction (a kernel an installed DKMS module's own + `Linux-Maximum` or `BUILD_EXCLUSIVE_KERNEL` excludes is held with its headers, `upgrade.kernel-held`), the + transaction as a unit of its own, its end, and the after-upgrade check (`.pacnew`/`.pacsave` files, the news + since the previous upgrade, units newly failed, a restart needed, processes still mapping a replaced file by + unit and executable, DKMS modules not built for an installed kernel). pacman's log is read once into `runs` + as history. + - **The round.** The nodes upgrade in the order of the mesh-level key `upgrade-order` (a trusted list, + `predefined.json`; the module's config value of the same name falls back to it). On Saturday at 04:00 local + time the first node of the order opens the week's round in `rounds`; each node's keeper watches `rounds` and + `runs` and starts its own run when the node before it passed its after-upgrade check. A failed run or check + stops the round there; only the operator passes a node (`pacman_round` with `pass` and a reason asks on the + operator's channel, and the round goes on only on the operator's Approve). With no order set no round opens. + - **Acts are asks.** `pacman_upgrade` and `pacman_rollback` with `apply` write an ask in `asks` at + `.`, which that node's keeper alone takes. + - **The root helper (ADR 0510).** The keeper never runs as root. The acts that need root go through a second + process of the same build, `pacman-root` (`pacman-tools root`), running as root with no bus account, on a + unix socket in `${dir:state}/root/` that only the keeper's account may open, the peer's account checked on + every connection. It serves four verbs and refuses everything else: start a run's full upgrade as the unit + `mesh-pacman-upgrade-` (leaving out held packages), start a rollback's unit with the plan in the request + (packages from the cache or the Arch Linux Archive, the dated repository's file found by name), read which + processes map a replaced file, and read a transaction unit's journal. No sudo rule is placed. + - **The health is read from the bus.** The keeper's health check (`pacman_upgrade_health`) is unhealthy while + no `upgrade-order` is set; while the node's latest run was refused, failed or did not pass its check (until + a later run passes); while its transaction goes on past two hours (hung, said and not killed); while a + kernel is held; and while the round stands still at this node or at the node it waits for, naming it and + since when. A node that slept through a run is judged by what the bus holds, not by a unit's age (issue + 1579). The weekly timer of ADR 0477 §5 and its service are dropped from the manifest, so the node-engine + removes them. ## What it improves @@ -62,21 +83,27 @@ that seat (ADR 0207). |---|---|---| | `pacman_search` | r | `pacman -Ss`: repository, name, version, groups, installed and at which version, description | | `pacman_info` | r | `-Qi`, or `-Si` when not installed, with lists as lists | -| `pacman_installed` | r | every package with version, explicit or dependency, foreign; filters and totals | +| `pacman_installed` | r | every package with version, explicit or dependency, foreign; filters and totals, from `installed` on the bus | +| `pacman_packages` | r | every node's packages from the bus: one package everywhere, differing versions, packages on some nodes only, each node's explicit packages no module declares, foreign apart, nodes not current | | `pacman_owns` | r | which package owns a path, or `owned: false` | | `pacman_files` | r | what a package placed, bounded | -| `pacman_updates` | r | `checkupdates`: what a full upgrade would change, never setting up a partial upgrade | -| `pacman_upgrade` | a | starts `pacman -Syu --noconfirm` (sudo -n) as a transient unit that outlives the call; answers the unit and the news since the last upgrade; given the unit, how it went | -| `pacman_upgrade_health` | r | whether the weekly upgrade last passed: the timer's health check (see the weekly upgrade above) | +| `pacman_updates` | r | what a full upgrade would change, for one node or all, from `updates` on the bus | +| `pacman_upgrade` | a | asks a node's keeper for a full upgrade as a run (an ask in `asks`); answers the ask and the news since the last upgrade; given a transaction unit, how it goes | +| `pacman_upgrade_health` | r | the keeper's health check, read from the bus (see the health above) | +| `pacman_upgrades` | r | every run of every node from `runs`: narrowed to a node and days, or one run whole | +| `pacman_round` | r/a | where every node stands in the newest rounds; `pass` with a reason asks the operator to let a round go on past a node | +| `pacman_rollback` | r/a | what returning a node to a run's record would change, each package with its source; `apply` asks the node's keeper to run it | | `pacman_orphans` | r | `pacman -Qdt` | | `pacman_remove_orphans` | a | `pacman -Rs` on named orphans, or all of them, as a unit of its own; a name that is not an orphan is refused | | `pacman_cache` | r/a | size, interrupted downloads, what paccache would free keeping N; `clean: true` removes them | | `pacman_history` | r | `/var/log/pacman.log`: installs, upgrades, downgrades, reinstalls and removals since a day, and the last full upgrade | | `pacman_mirrors` | r/a | the list, its generator and age, reflector's options, timer and last run; `refresh: true` starts reflector | -| `pacman_foreign` | r | `pacman -Qm` | +| `pacman_foreign` | r | the foreign packages, from `installed` on the bus | | `pacman_news` | r | the distribution's news since the last full upgrade (or a day), over https; no network is `reachable: false` | | `pacman_config` | r | `pacman-conf`: options, repositories, and whether `/etc/pacman.conf` is the module's | A transaction never runs as the tool's own child. An upgrade takes longer than the 20 s a call may take, and a pacman killed mid-transaction leaves a half-upgraded machine and a lock. So a transaction -runs as a transient unit (`systemd-run`, named `mesh-pacman-…`), and its log is read from the journal. +runs as a transient unit (`systemd-run`, named `mesh-pacman-…`), and its log is read from the journal. A +run's transaction is started by the root helper; removing orphans and cleaning the cache still go through +`sudo -n` from the tools bundle. diff --git a/modules/pacman/cmd/pacman-tools/acts.go b/modules/pacman/cmd/pacman-tools/acts.go index 146d6d5e..ef3682ef 100644 --- a/modules/pacman/cmd/pacman-tools/acts.go +++ b/modules/pacman/cmd/pacman-tools/acts.go @@ -55,30 +55,11 @@ func (m *Machine) journal(unit string, n int) []string { return lines(out) } -// UpgradeArgs is the full system upgrade, in one place: pacman_upgrade starts pacman with these arguments as -// a transient unit, and the weekly upgrade's service (files/mesh-pacman-weekly-upgrade.service) runs -// pacman with the same ones; a test holds the two together (issue 1574, ADR 0477 clause 5). +// UpgradeArgs is the full system upgrade, in one place: the root helper starts a run's transaction with these +// arguments (ADR 0510), and the packages a run holds back are added to them as --ignore. var UpgradeArgs = []string{"-Syu", "--noconfirm"} -// Upgrade starts a full system upgrade as a transient unit and answers at once, with the -// distribution's news since the last upgrade; or, given a unit it started, answers how it went. -func (m *Machine) Upgrade(unit string, n int) (map[string]any, error) { - if unit != "" { - return m.UpgradeStatus(unit, n) - } - news := m.News("") - started, _, err := m.transaction("upgrade", false, UpgradeArgs...) - if err != nil { - return nil, err - } - return map[string]any{ - "started": started, - "follow": "call pacman_upgrade with this unit to read how it goes", - "news": news, - }, nil -} - -var unitName = regexp.MustCompile(`^` + unitPrefix + `[a-z-]+-[0-9]+$`) +var unitName = regexp.MustCompile(`^` + unitPrefix + `[a-z-]+-[0-9a-z]+$`) // UpgradeStatus is a transaction unit's state and the tail of what it logged. func (m *Machine) UpgradeStatus(unit string, n int) (map[string]any, error) { diff --git a/modules/pacman/cmd/pacman-tools/aftercheck.go b/modules/pacman/cmd/pacman-tools/aftercheck.go new file mode 100644 index 00000000..d7a4c05b --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/aftercheck.go @@ -0,0 +1,151 @@ +package main + +// The after-upgrade check (novox/hq ADR 0508 §4), written to the run as its last step: the .pacnew and .pacsave +// files the transaction left, the distribution's news since the node's previous upgrade, the units failed now +// that were not failed in the record, and whether a restart is needed (the running kernel is no longer the one +// its package installed). It passes when the transaction succeeded and no unit newly failed (and, with the +// second part, every DKMS module is built); the rest is said and does not stop the round. The mesh restarts no +// machine (ADR 0477 §5). + +import ( + "sort" + "strings" + "time" +) + +// AfterCheck is the after-upgrade check of a run whose transaction ended, against the record taken before it +// (nil when there is none: every unit failed now is then said as newly failed). +func (u *Upgrades) AfterCheck(run Run, rec *Record) Check { + c := Check{Pacnew: []string{}, Pacsave: []string{}, News: []NewsTitle{}, Mapped: []Mapped{}, NewlyFailed: []string{}, Unbuilt: []DKMSBuild{}} + succeeded := transactionSucceeded(run) + var started time.Time + for _, s := range run.Steps { + if s.Step == StepStarted { + started = s.At + } + } + if !started.IsZero() { + if b := u.transactionBlock(run.Kind, started); b != nil { + c.Pacnew = append(c.Pacnew, b.Pacnew...) + c.Pacsave = append(c.Pacsave, b.Pacsave...) + } + } + c.News, c.NewsError = u.newsSince(u.previousUpgrade(run)) + healthy := true + if failed, err := u.Machine.FailedUnits(); err != nil { + healthy = false + c.NewlyFailed = append(c.NewlyFailed, "(the failed units could not be read: "+err.Error()+")") + } else { + before := map[string]bool{} + if rec != nil { + for _, f := range rec.FailedUnits { + before[f] = true + } + } + for _, f := range failed { + if !before[f] { + c.NewlyFailed = append(c.NewlyFailed, f) + } + } + } + c.RunningKernel, c.InstalledKernel, c.RestartNeeded = u.restartNeeded() + c.Passed = succeeded && healthy && len(c.NewlyFailed) == 0 + u.afterCheckII(&c) + return c +} + +// previousUpgrade is when the node's upgrade before this run started: its previous run with a transaction. +func (u *Upgrades) previousUpgrade(run Run) time.Time { + runs, err := u.ownRuns() + if err != nil { + return time.Time{} + } + var last time.Time + for _, r := range runs { + if r.Run.Run >= run.Run || r.Kind != RunUpgrade { + continue + } + for _, s := range r.Steps { + if s.Step == StepStarted && s.At.After(last) { + last = s.At + } + } + } + return last +} + +// newsSince is the distribution's news published after a time, titles only; or why it could not be read. +func (u *Upgrades) newsSince(since time.Time) ([]NewsTitle, string) { + body, err := fetch(NewsFeed) + if err != nil { + return []NewsTitle{}, err.Error() + } + items, err := ParseNews(body, since) + if err != nil { + return []NewsTitle{}, "the feed could not be read: " + err.Error() + } + titles := []NewsTitle{} + for _, it := range items { + titles = append(titles, NewsTitle{Title: it.Title, Link: it.Link, Published: it.Published}) + } + return titles, "" +} + +// kernelTag is a release's flavour, its letters alone: `7.2.8-arch1-2` is arch, `6.18.54-2-lts` is lts. +func kernelTag(release string) string { + return strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' { + return r + } + return -1 + }, release) +} + +// installedKernels are the releases the installed kernel packages install, sorted. +func (m *Machine) installedKernels() ([]string, error) { + out, err := m.Out("pacman", "-Qq") + if err != nil { + return nil, err + } + var releases []string + for _, name := range lines(out) { + name = strings.TrimSpace(name) + if name != "linux" && !strings.HasPrefix(name, "linux-") { + continue + } + release, kernel, err := m.kernelRelease(name) + if err != nil { + return nil, err + } + if kernel { + releases = append(releases, release) + } + } + sort.Strings(releases) + return releases, nil +} + +// restartNeeded compares the running kernel with what its package installs now: when no installed kernel is the +// running one, the one of its flavour is what a restart would boot. +func (u *Upgrades) restartNeeded() (running, installed string, needed bool) { + raw, err := u.Machine.ReadFile(OSRelease) + if err != nil { + return "(the running kernel could not be read: " + err.Error() + ")", "", false + } + running = strings.TrimSpace(string(raw)) + kernels, err := u.Machine.installedKernels() + if err != nil { + return running, "(the installed kernels could not be read: " + err.Error() + ")", false + } + for _, k := range kernels { + if k == running { + return running, k, false + } + } + for _, k := range kernels { + if kernelTag(k) == kernelTag(running) { + return running, k, true + } + } + return running, "", true +} diff --git a/modules/pacman/cmd/pacman-tools/aftercheck_test.go b/modules/pacman/cmd/pacman-tools/aftercheck_test.go new file mode 100644 index 00000000..5410f8bb --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/aftercheck_test.go @@ -0,0 +1,121 @@ +package main + +// The after-upgrade check, its first part (novox/hq ADR 0508 §4; acceptance 1841/AC5): .pacnew and .pacsave +// files from the transaction's log lines, the news since the previous upgrade, units newly failed against the +// record, and a restart needed. + +import ( + "errors" + "strings" + "testing" + "time" +) + +const checkLog = "[2026-10-04T12:00:01+0000] [PACMAN] Running 'pacman -Syu --noconfirm'\n" + + "[2026-10-04T12:00:02+0000] [PACMAN] starting full system upgrade\n" + + "[2026-10-04T12:00:09+0000] [ALPM] transaction started\n" + + "[2026-10-04T12:00:10+0000] [ALPM] upgraded pacman-mirrorlist (20260901-1 -> 20261001-1)\n" + + "[2026-10-04T12:00:10+0000] [ALPM] warning: /etc/pacman.d/mirrorlist installed as /etc/pacman.d/mirrorlist.pacnew\n" + + "[2026-10-04T12:00:10+0000] [ALPM] warning: /etc/lemurs/config.toml saved as /etc/lemurs/config.toml.pacsave\n" + + "[2026-10-04T12:00:10+0000] [ALPM] removed lemurs-git (0.4.0.r17.g7151336-2)\n" + + "[2026-10-04T12:00:11+0000] [ALPM] transaction completed\n" + + "[2026-10-04T12:05:00+0000] [PACMAN] Running 'pacman -S --noconfirm made-up'\n" + + "[2026-10-04T12:05:01+0000] [ALPM] transaction started\n" + + "[2026-10-04T12:05:01+0000] [ALPM] warning: /etc/made-up.conf installed as /etc/made-up.conf.pacnew\n" + + "[2026-10-04T12:05:02+0000] [ALPM] transaction completed\n" + +const newsFeed = ` +A manual interventionhttps://example.org/news/2Fri, 02 Oct 2026 10:00:00 +0000x +An older posthttps://example.org/news/1Mon, 21 Sep 2026 10:00:00 +0000y +` + +// checkMachine is a machine after an upgrade: these units failed now, this kernel running, and the linux +// package now installing linuxNow. +func checkMachine(failed, linuxNow string) *Machine { + m := machine(byLine(map[string]Ran{ + "systemctl list-units --state=failed --plain --no-legend --no-pager": {Stdout: failed}, + "pacman -Qq": {Stdout: "bash\nlinux\nlinux-firmware\nlinux-lts\n"}, + "dkms status": {Status: 127, Err: "ENOENT"}, + "pacman -Qlq -- linux": {Stdout: "/usr/lib/modules/" + linuxNow + "/vmlinuz\n"}, + "pacman -Qlq -- linux-firmware": {Stdout: "/usr/lib/firmware/\n"}, + "pacman -Qlq -- linux-lts": {Stdout: "/usr/lib/modules/6.18.54-2-lts/vmlinuz\n"}, + }, nil), 1000) + m.ReadFile = func(path string) ([]byte, error) { + switch path { + case OSRelease: + return []byte("7.2.8-arch1-2\n"), nil + case PacmanLog: + return []byte(checkLog), nil + } + return nil, errNoFile + } + return m +} + +func checkedRun(steps ...string) Run { + r := Run{Node: "node-a", Run: "20261004T120000Z", Kind: RunUpgrade, From: FromAsk} + at := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) + for _, s := range steps { + r.Steps = append(r.Steps, Step{Step: s, At: at}) + } + return r +} + +func checkUpgrades(t *testing.T, m *Machine) *Upgrades { + runs := newBucket() + previous := Run{Node: "node-a", Run: "20260926T040000Z", Kind: RunUpgrade, From: FromRound, + Steps: []Step{{Step: StepStarted, At: time.Date(2026, 9, 26, 4, 0, 0, 0, time.UTC)}}} + if _, err := runs.Create(NodeKey("node-a", previous.Run), previous); err != nil { + t.Fatal(err) + } + saved := fetch + fetch = func(string) ([]byte, error) { return []byte(newsFeed), nil } + t.Cleanup(func() { fetch = saved }) + return &Upgrades{Node: "node-a", Machine: m, Runs: runs} +} + +func TestTheCheckNamesTheFilesTheTransactionLeftAndTheNewsSinceThePreviousUpgrade(t *testing.T) { + u := checkUpgrades(t, checkMachine("", "7.2.8-arch1-2")) + c := u.AfterCheck(checkedRun(StepStarted, StepFinished), &Record{}) + if strings.Join(c.Pacnew, " ") != "/etc/pacman.d/mirrorlist.pacnew" || strings.Join(c.Pacsave, " ") != "/etc/lemurs/config.toml.pacsave" { + t.Errorf("only this transaction's files: %v %v", c.Pacnew, c.Pacsave) + } + if len(c.News) != 1 || c.News[0].Title != "A manual intervention" || c.NewsError != "" { + t.Errorf("the news since the previous upgrade (26 September): %+v %q", c.News, c.NewsError) + } + if !c.Passed || c.RestartNeeded || c.RunningKernel != "7.2.8-arch1-2" || c.InstalledKernel != "7.2.8-arch1-2" { + t.Errorf("%+v", c) + } +} + +func TestNewsThatCannotBeReadIsSaidNotShownAsNone(t *testing.T) { + u := checkUpgrades(t, checkMachine("", "7.2.8-arch1-2")) + fetch = func(string) ([]byte, error) { return nil, errors.New("no route to host") } + c := u.AfterCheck(checkedRun(StepStarted, StepFinished), &Record{}) + if !strings.Contains(c.NewsError, "no route to host") || !c.Passed { + t.Errorf("%+v", c) + } +} + +func TestAUnitNewlyFailedFailsTheCheckAndOneFailedBeforeDoesNot(t *testing.T) { + u := checkUpgrades(t, checkMachine("backup.service loaded failed failed The backup\nweb.service loaded failed failed The web\n", "7.2.8-arch1-2")) + c := u.AfterCheck(checkedRun(StepStarted, StepFinished), &Record{FailedUnits: []string{"backup.service"}}) + if strings.Join(c.NewlyFailed, " ") != "web.service" || c.Passed { + t.Errorf("%+v", c) + } +} + +func TestARestartIsNeededWhenTheRunningKernelIsNoLongerTheInstalledOne(t *testing.T) { + u := checkUpgrades(t, checkMachine("", "7.2.9-arch1-1")) + c := u.AfterCheck(checkedRun(StepStarted, StepFinished), &Record{}) + if !c.RestartNeeded || c.InstalledKernel != "7.2.9-arch1-1" || !c.Passed { + t.Errorf("a restart is said and does not fail the check: %+v", c) + } +} + +func TestTheCheckPassesOnlyWhenTheTransactionSucceeded(t *testing.T) { + u := checkUpgrades(t, checkMachine("", "7.2.8-arch1-2")) + if c := u.AfterCheck(checkedRun(StepStarted, StepFailed), &Record{}); c.Passed { + t.Errorf("%+v", c) + } +} diff --git a/modules/pacman/cmd/pacman-tools/bus_fake_test.go b/modules/pacman/cmd/pacman-tools/bus_fake_test.go new file mode 100644 index 00000000..0d90a846 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/bus_fake_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// memBucket is a state in memory, with the bus's revisions and its refusal of a lost race. +type memBucket struct { + mu sync.Mutex + values map[string][]byte + revs map[string]uint64 + rev uint64 + writes []string // "op key", in order + fail map[string]error +} + +func newBucket() *memBucket { + return &memBucket{values: map[string][]byte{}, revs: map[string]uint64{}, fail: map[string]error{}} +} + +func (b *memBucket) Get(key string) (*stdio.StateEntry, error) { + b.mu.Lock() + defer b.mu.Unlock() + if err := b.fail["get "+key]; err != nil { + return nil, err + } + v, ok := b.values[key] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: key, Value: json.RawMessage(v), Revision: b.revs[key]}, nil +} + +func (b *memBucket) write(op, key string, value any) (uint64, error) { + if err := b.fail[op+" "+key]; err != nil { + return 0, err + } + data, err := json.Marshal(value) + if err != nil { + return 0, err + } + b.rev++ + b.values[key], b.revs[key] = data, b.rev + b.writes = append(b.writes, op+" "+key) + return b.rev, nil +} + +func (b *memBucket) Create(key string, value any) (uint64, error) { + b.mu.Lock() + defer b.mu.Unlock() + if _, ok := b.values[key]; ok { + return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) + } + return b.write("create", key, value) +} + +func (b *memBucket) Update(key string, value any, revision uint64) (uint64, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.revs[key] != revision { + return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) + } + return b.write("update", key, value) +} + +func (b *memBucket) Put(key string, value any) (uint64, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.write("put", key, value) +} + +func (b *memBucket) Delete(key string) error { + b.mu.Lock() + defer b.mu.Unlock() + if err := b.fail["delete "+key]; err != nil { + return err + } + delete(b.values, key) + delete(b.revs, key) + b.writes = append(b.writes, "delete "+key) + return nil +} + +func (b *memBucket) Keys() ([]string, error) { + b.mu.Lock() + defer b.mu.Unlock() + keys := []string{} + for k := range b.values { + keys = append(keys, k) + } + sort.Strings(keys) + return keys, nil +} + +// value reads a key into its shape; a missing key is the zero value and false. +func (b *memBucket) value(key string, v any) bool { + b.mu.Lock() + defer b.mu.Unlock() + raw, ok := b.values[key] + if ok { + if err := json.Unmarshal(raw, v); err != nil { + panic(err) + } + } + return ok +} + +// writesTo are the writes to keys starting with prefix. +func (b *memBucket) writesTo(prefix string) []string { + b.mu.Lock() + defer b.mu.Unlock() + var out []string + for _, w := range b.writes { + if _, key, _ := strings.Cut(w, " "); strings.HasPrefix(key, prefix) { + out = append(out, w) + } + } + return out +} + +// sentEvent is one event a fake sink was handed. +type sentEvent struct { + Name string + Body any +} + +type eventLog struct { + mu sync.Mutex + sent []sentEvent +} + +func (e *eventLog) Emit(name string, body any, _ ...stdio.EmitOptions) error { + e.mu.Lock() + defer e.mu.Unlock() + e.sent = append(e.sent, sentEvent{name, body}) + return nil +} + +func (e *eventLog) names() []string { + e.mu.Lock() + defer e.mu.Unlock() + var out []string + for _, s := range e.sent { + out = append(out, s.Name) + } + return out +} diff --git a/modules/pacman/cmd/pacman-tools/busshapes_test.go b/modules/pacman/cmd/pacman-tools/busshapes_test.go new file mode 100644 index 00000000..afabca1b --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/busshapes_test.go @@ -0,0 +1,203 @@ +package main + +// The keeper's bus shapes carry only their named fields (novox/hq ADR 0507 §1, AC7; ADR 0508): every key's value +// and every event's body, filled whole, marshals to exactly the fields named here, and no shape holds a map or a +// free-form value through which anything else could reach the bus. + +import ( + "encoding/json" + "reflect" + "regexp" + "sort" + "strings" + "testing" + "time" +) + +// busFields is every shape's JSON fields, by Go type: the interface the keeper and the verbs share. +var busFields = map[string][]string{ + "InstalledValue": {"at", "packages"}, + "InstalledPackage": {"name", "version", "explicit", "repo"}, + "NotCurrent": {"bytes", "since"}, + "UpdatesValue": {"reachable", "packages"}, + "VersionChange": {"name", "from", "to"}, + "PackageVersion": {"name", "version"}, + "Run": {"node", "run", "kind", "from", "round", "ask", "of", "steps"}, + "Step": {"step", "at", "why", "held", "unit", "ignored", "plan", "exit", "error", "changes", "check"}, + "HeldKernel": {"package", "version", "module", "bound"}, + "Changes": {"added", "removed", "changed"}, + "Check": {"passed", "pacnew", "pacsave", "news", "news_error", "mapped", "mapped_error", "newly_failed", "restart_needed", "running_kernel", + "installed_kernel", "unbuilt", "dkms_error"}, + "NewsTitle": {"title", "link", "published"}, + "Mapped": {"unit", "container", "executable"}, + "DKMSBuild": {"module", "version", "kernel"}, + "RollbackItem": {"name", "from", "to", "source", "location"}, + "Record": {"node", "run", "at", "installed", "kernel", "failed_units", "dkms"}, + "DKMSModule": {"module", "version", "kernels"}, + "Round": {"round", "opened", "opened_by", "order"}, + "RoundNode": {"round", "node", "run", "pass"}, + "Pass": {"reason", "ask", "asked", "answer", "answered"}, + "Ask": {"id", "node", "act", "at", "of", "remove_added", "round", "pass", "reason"}, + "PackageChanged": {"node", "at", "added", "removed", "changed"}, + "UpgradeRecorded": {"node", "run", "packages", "kernel"}, + "KernelHeld": {"node", "run", "held"}, + "UpgradeStarted": {"node", "run", "from", "round", "unit", "ignored"}, + "UpgradeFinished": {"node", "run", "changes"}, + "UpgradeFailed": {"node", "run", "step", "exit", "error"}, + "UpgradeChecked": {"node", "run", "kind", "passed"}, + "RollbackStarted": {"node", "run", "of", "unit"}, + "RollbackFinished": {"node", "run", "of", "exit", "error"}, +} + +// The value of each state's keys. +var stateValues = []any{InstalledValue{}, NotCurrent{}, UpdatesValue{}, Run{}, Record{}, Round{}, RoundNode{}, Ask{}} + +var stamp = time.Date(2026, 10, 24, 4, 0, 12, 0, time.UTC) + +// fill sets every field of v, recursively, to a value that is not empty: one element in each list, every pointer +// set, so that every field marshals, omitempty or not. +func fill(v reflect.Value) { + switch v.Kind() { + case reflect.String: + v.SetString("x") + case reflect.Bool: + v.SetBool(true) + case reflect.Int, reflect.Int64: + v.SetInt(1) + case reflect.Pointer: + v.Set(reflect.New(v.Type().Elem())) + fill(v.Elem()) + case reflect.Slice: + s := reflect.MakeSlice(v.Type(), 1, 1) + fill(s.Index(0)) + v.Set(s) + case reflect.Struct: + if v.Type() == reflect.TypeOf(time.Time{}) { + v.Set(reflect.ValueOf(stamp)) + return + } + for i := 0; i < v.NumField(); i++ { + fill(v.Field(i)) + } + } +} + +// fieldsOf walks a filled value against its marshalled JSON: each struct's object holds exactly its named +// fields. It answers the struct types it met. +func fieldsOf(t *testing.T, v reflect.Value, raw json.RawMessage, met map[string]bool) { + t.Helper() + switch v.Kind() { + case reflect.Map, reflect.Interface: + t.Errorf("%s holds a free-form value: a bus shape names its fields", v.Type()) + case reflect.Pointer: + fieldsOf(t, v.Elem(), raw, met) + case reflect.Slice: + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil || len(items) != v.Len() { + t.Errorf("%s: %s is not its list", v.Type(), raw) + return + } + for i := range items { + fieldsOf(t, v.Index(i), items[i], met) + } + case reflect.Struct: + if v.Type() == reflect.TypeOf(time.Time{}) { + return + } + name := v.Type().Name() + met[name] = true + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + t.Errorf("%s: %s is not an object", name, raw) + return + } + var got []string + for k := range obj { + got = append(got, k) + } + want, named := busFields[name] + if !named { + t.Errorf("%s is on the bus and its fields are not named in busFields", name) + return + } + want = append([]string(nil), want...) + sort.Strings(got) + sort.Strings(want) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("%s marshals to %v, its named fields are %v", name, got, want) + } + for i := 0; i < v.NumField(); i++ { + tag, _, _ := strings.Cut(v.Type().Field(i).Tag.Get("json"), ",") + if r, ok := obj[tag]; ok { + fieldsOf(t, v.Field(i), r, met) + } + } + } +} + +func TestEveryStateValueAndEventBodyCarriesOnlyItsNamedFields(t *testing.T) { + roots := append([]any(nil), stateValues...) + for _, e := range Events { + roots = append(roots, e.Body) + } + met := map[string]bool{} + for _, root := range roots { + v := reflect.New(reflect.TypeOf(root)).Elem() + fill(v) + raw, err := json.Marshal(v.Interface()) + if err != nil { + t.Fatal(err) + } + fieldsOf(t, v, raw, met) + } + for name := range busFields { + if !met[name] { + t.Errorf("busFields names %s, which no state value or event body holds", name) + } + } +} + +func TestEveryEventIsNamedOnceWithItsBody(t *testing.T) { + seen := map[string]bool{} + for _, e := range Events { + if seen[e.Name] { + t.Errorf("%s twice", e.Name) + } + seen[e.Name] = true + if e.Body == nil || reflect.TypeOf(e.Body).Kind() != reflect.Struct { + t.Errorf("%s has no body type", e.Name) + } + } + if len(Events) != 9 { + t.Errorf("package.changed and the eight upgrade and rollback events, not %d", len(Events)) + } +} + +// A key is node and id tokens joined by dots; each token is one NATS key token. +func TestKeysAreTokensJoinedByDots(t *testing.T) { + token := regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + run := RunID(time.Date(2026, 10, 24, 6, 0, 12, 0, time.FixedZone("CEST", 2*3600))) + if run != "20261024T040012Z" { + t.Errorf("RunID: %s", run) + } + round := RoundID(time.Date(2026, 10, 24, 4, 0, 0, 0, time.Local)) + if round != "2026-W43" { + t.Errorf("RoundID: %s", round) + } + if RoundID(time.Date(2027, 1, 2, 4, 0, 0, 0, time.UTC)) != "2026-W53" { + t.Errorf("the ISO week of 2 January 2027 is 2026's 53rd: %s", RoundID(time.Date(2027, 1, 2, 4, 0, 0, 0, time.UTC))) + } + for key, parts := range map[string]int{ + NodeKey("node-a", run): 2, NotCurrentKey("node-a"): 2, RoundNodeKey(round, "node-b"): 2, + } { + ts := strings.Split(key, ".") + if len(ts) != parts { + t.Errorf("%s: %d tokens", key, len(ts)) + } + for _, x := range ts { + if !token.MatchString(x) { + t.Errorf("%s: token %q", key, x) + } + } + } +} diff --git a/modules/pacman/cmd/pacman-tools/dkms.go b/modules/pacman/cmd/pacman-tools/dkms.go new file mode 100644 index 00000000..11f324a6 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/dkms.go @@ -0,0 +1,166 @@ +package main + +// The DKMS gate (novox/hq ADR 0508 §3): before the transaction, every kernel package the upgrade would change is +// compared with the kernel bound each installed DKMS module declares itself: `Linux-Maximum` in its source's +// META, or `BUILD_EXCLUSIVE_KERNEL` (a pattern of kernel releases) in its dkms.conf. A kernel past a bound is +// left out of the run with its headers, and the rest is upgraded. A kernel package is known by its files, not +// its name: one whose files hold usr/lib/modules//vmlinuz. + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +var ( + vmlinuzPath = regexp.MustCompile(`^/?usr/lib/modules/([^/]+)/vmlinuz$`) + linuxMaximum = regexp.MustCompile(`(?m)^Linux-Maximum:\s*(\S+)\s*$`) + buildExclusive = regexp.MustCompile(`(?m)^BUILD_EXCLUSIVE_KERNEL=(?:"([^"]*)"|'([^']*)'|(\S+))\s*$`) + leadingNumber = regexp.MustCompile(`^[0-9]+(\.[0-9]+)*`) +) + +// kernelRelease is the release an installed kernel package's image is for, and whether the package is a kernel. +func (m *Machine) kernelRelease(pkg string) (string, bool, error) { + out, err := m.Out("pacman", "-Qlq", "--", pkg) + if err != nil { + return "", false, fmt.Errorf("the files of %s: %w", pkg, err) + } + for _, l := range lines(out) { + if g := vmlinuzPath.FindStringSubmatch(strings.TrimSpace(l)); g != nil { + return g[1], true, nil + } + } + return "", false, nil +} + +// NewRelease is the release a kernel package's new version will install, derived from it as the installed +// release is from the installed version: `7.2.8.arch1-2` installs `7.2.8-arch1-2`, `6.18.54-2` (the LTS +// kernel) installs `6.18.54-2-lts`. +func NewRelease(installedVersion, installedRelease, newVersion string) string { + base := func(v string) string { return strings.Replace(v, ".arch", "-arch", 1) } + suffix := "" + if b := base(installedVersion); strings.HasPrefix(installedRelease, b) { + suffix = strings.TrimPrefix(installedRelease, b) + } + return base(newVersion) + suffix +} + +// pastMaximum says whether a release is past a Linux-Maximum bound, compared on as many numbers as the bound +// gives: 7.3.1 is past 7.2, 7.2.9 is not. +func pastMaximum(release, maximum string) bool { + bound := strings.Split(leadingNumber.FindString(maximum), ".") + have := strings.Split(leadingNumber.FindString(release), ".") + if bound[0] == "" || have[0] == "" { + return false + } + for i, b := range bound { + bn, _ := strconv.Atoi(b) + hn := 0 + if i < len(have) { + hn, _ = strconv.Atoi(have[i]) + } + if hn != bn { + return hn > bn + } + } + return false +} + +// dkmsBound is the bound one DKMS module declares, read from its source. +type dkmsBound struct { + Maximum string + Exclusive string +} + +func (m *Machine) dkmsBound(mod DKMSModule) dkmsBound { + dir := "/usr/src/" + mod.Module + "-" + mod.Version + "/" + var b dkmsBound + if meta, err := m.ReadFile(dir + "META"); err == nil { + if g := linuxMaximum.FindSubmatch(meta); g != nil { + b.Maximum = string(g[1]) + } + } + if conf, err := m.ReadFile(dir + "dkms.conf"); err == nil { + if g := buildExclusive.FindSubmatch(conf); g != nil { + b.Exclusive = string(g[1]) + string(g[2]) + string(g[3]) + } + } + return b +} + +// heldBy says which bound of a module holds a release, or nothing. +func heldBy(b dkmsBound, release string) (string, error) { + if b.Maximum != "" && pastMaximum(release, b.Maximum) { + return "Linux-Maximum: " + b.Maximum, nil + } + if b.Exclusive != "" { + re, err := regexp.Compile(b.Exclusive) + if err != nil { + return "", fmt.Errorf("BUILD_EXCLUSIVE_KERNEL %q is not a pattern this reads: %w", b.Exclusive, err) + } + if !re.MatchString(release) { + return "BUILD_EXCLUSIVE_KERNEL=" + b.Exclusive, nil + } + } + return "", nil +} + +// Gate is the DKMS gate for the node's next run: the kernels it holds, the packages left out of the transaction +// for them (each held kernel and its headers), or why the run cannot start. +func (u *Upgrades) Gate() (held []HeldKernel, ignore []string, why string) { + e, err := u.Updates.Get(u.Node) + if err != nil { + return nil, nil, "what an upgrade would change could not be read from the bus: " + err.Error() + } + if e == nil { + return nil, nil, "what an upgrade would change is not on the bus yet, so no kernel it would bring can be checked" + } + var updates UpdatesValue + if err := json.Unmarshal(e.Value, &updates); err != nil { + return nil, nil, "what an upgrade would change is not readable: " + err.Error() + } + if !updates.Reachable { + return nil, nil, "the node could not reach its mirrors, so what an upgrade would change is not known" + } + var modules []DKMSModule + var bounds []dkmsBound + held, ignore = []HeldKernel{}, []string{} + for _, p := range updates.Packages { + if p.Name != "linux" && !strings.HasPrefix(p.Name, "linux-") { + continue + } + release, kernel, err := u.Machine.kernelRelease(p.Name) + if err != nil { + return nil, nil, "whether " + p.Name + " is a kernel could not be read: " + err.Error() + } + if !kernel { + continue + } + if modules == nil { + if modules, err = u.Machine.DKMSModules(); err != nil { + return nil, nil, "the DKMS modules could not be read: " + err.Error() + } + for _, mod := range modules { + b := u.Machine.dkmsBound(mod) + bounds = append(bounds, b) + } + } + next := NewRelease(p.From, release, p.To) + for i, mod := range modules { + bound, err := heldBy(bounds[i], next) + if err != nil { + return nil, nil, mod.Module + " " + mod.Version + ": " + err.Error() + } + if bound == "" { + continue + } + held = append(held, HeldKernel{Package: p.Name, Version: p.To, Module: mod.Module + "/" + mod.Version, Bound: bound}) + if !contains(ignore, p.Name) { + ignore = append(ignore, p.Name, p.Name+"-headers") + } + } + } + return held, ignore, "" +} diff --git a/modules/pacman/cmd/pacman-tools/dkms_test.go b/modules/pacman/cmd/pacman-tools/dkms_test.go new file mode 100644 index 00000000..1c396138 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/dkms_test.go @@ -0,0 +1,115 @@ +package main + +// The DKMS gate (novox/hq ADR 0508 §3; acceptance 1841/AC4). The META is captured from a machine whose ZFS +// module declares Linux-Maximum 7.2; the BUILD_EXCLUSIVE_KERNEL case is a made-up module's pattern. + +import ( + "os" + "strings" + "testing" +) + +func TestTheNewKernelsReleaseIsDerivedAsTheInstalledOnesIs(t *testing.T) { + for _, c := range [][4]string{ + {"7.2.8.arch1-2", "7.2.8-arch1-2", "7.3.1.arch1-1", "7.3.1-arch1-1"}, + {"6.18.54-2", "6.18.54-2-lts", "6.18.60-1", "6.18.60-1-lts"}, + } { + if got := NewRelease(c[0], c[1], c[2]); got != c[3] { + t.Errorf("%v: %s", c, got) + } + } + for release, past := range map[string]bool{"7.3.1-arch1-1": true, "7.2.9-arch1-1": false, "7.2-arch1-1": false, "8.0.1": true, "6.18.60-1-lts": false} { + if pastMaximum(release, "7.2") != past { + t.Errorf("%s past 7.2: want %v", release, past) + } + } +} + +// gateMachine is a machine with the linux and LTS kernels and these DKMS modules' sources. +func gateMachine(t *testing.T, dkmsStatus string, sources map[string]string) *Machine { + files := func(paths ...string) Ran { return Ran{Stdout: strings.Join(paths, "\n") + "\n"} } + m := machine(byLine(map[string]Ran{ + "pacman -Qlq -- linux": files("/usr/lib/modules/7.2.8-arch1-2/", "/usr/lib/modules/7.2.8-arch1-2/vmlinuz"), + "pacman -Qlq -- linux-headers": files("/usr/lib/modules/7.2.8-arch1-2/build/Makefile"), + "pacman -Qlq -- linux-lts": files("/usr/lib/modules/6.18.54-2-lts/vmlinuz"), + "pacman -Qlq -- linux-firmware": files("/usr/lib/firmware/amdgpu/"), + "dkms status": {Stdout: dkmsStatus}, + }, nil), 1000) + m.ReadFile = func(path string) ([]byte, error) { + if s, ok := sources[path]; ok { + return []byte(s), nil + } + return nil, errNoFile + } + return m +} + +func capturedZFSSource(t *testing.T) map[string]string { + out := map[string]string{} + for _, f := range []string{"META", "dkms.conf"} { + raw, err := os.ReadFile("testdata/dkms-zfs-linux-maximum-7.2/" + f) + if err != nil { + t.Fatal(err) + } + out["/usr/src/zfs-2.4.4/"+f] = string(raw) + } + return out +} + +func gateUpgrades(t *testing.T, m *Machine, linuxTo string, reachable bool) *Upgrades { + updates := newBucket() + if _, err := updates.Put("node-a", UpdatesValue{Reachable: reachable, Packages: []VersionChange{ + {Name: "bash", From: "5.3.15-1", To: "5.3.20-1"}, + {Name: "linux", From: "7.2.8.arch1-2", To: linuxTo}, + {Name: "linux-firmware", From: "20261001-1", To: "20261015-1"}, + {Name: "linux-headers", From: "7.2.8.arch1-2", To: linuxTo}, + {Name: "linux-lts", From: "6.18.54-2", To: "6.18.60-1"}, + }}); err != nil { + t.Fatal(err) + } + return &Upgrades{Node: "node-a", Machine: m, Updates: updates} +} + +const zfsStatus = "zfs/2.4.4, 7.2.8-arch1-2, x86_64: installed\nzfs/2.4.4, 6.18.54-2-lts, x86_64: installed\n" + +func TestAKernelPastAModulesLinuxMaximumIsHeldWithItsHeaders(t *testing.T) { + u := gateUpgrades(t, gateMachine(t, zfsStatus, capturedZFSSource(t)), "7.3.1.arch1-1", true) + held, ignore, why := u.Gate() + if why != "" { + t.Fatal(why) + } + if len(held) != 1 || held[0] != (HeldKernel{Package: "linux", Version: "7.3.1.arch1-1", Module: "zfs/2.4.4", Bound: "Linux-Maximum: 7.2"}) { + t.Errorf("held: %+v", held) + } + if strings.Join(ignore, " ") != "linux linux-headers" { + t.Errorf("left out: %v (the LTS kernel stays within the bound and is upgraded)", ignore) + } +} + +func TestAKernelWithinTheBoundIsNotHeld(t *testing.T) { + u := gateUpgrades(t, gateMachine(t, zfsStatus, capturedZFSSource(t)), "7.2.9.arch1-1", true) + if held, ignore, why := u.Gate(); why != "" || len(held) != 0 || len(ignore) != 0 { + t.Errorf("%+v %v %q", held, ignore, why) + } +} + +func TestABuildExclusivePatternHoldsTheKernelsItDoesNotMatch(t *testing.T) { + src := map[string]string{"/usr/src/made-up-driver-1.0/dkms.conf": "PACKAGE_NAME=\"made-up-driver\"\nBUILD_EXCLUSIVE_KERNEL=\"^(6\\.|7\\.[0-2]\\.)\"\n"} + u := gateUpgrades(t, gateMachine(t, "made-up-driver/1.0, 7.2.8-arch1-2, x86_64: installed\n", src), "7.3.1.arch1-1", true) + held, ignore, why := u.Gate() + if why != "" || len(held) != 1 || held[0].Package != "linux" || held[0].Bound != `BUILD_EXCLUSIVE_KERNEL=^(6\.|7\.[0-2]\.)` || + strings.Join(ignore, " ") != "linux linux-headers" { + t.Errorf("%+v %v %q", held, ignore, why) + } +} + +func TestNoRunWhenWhatTheUpgradeWouldChangeIsNotKnown(t *testing.T) { + u := gateUpgrades(t, gateMachine(t, zfsStatus, capturedZFSSource(t)), "7.3.1.arch1-1", false) + if _, _, why := u.Gate(); !strings.Contains(why, "could not reach its mirrors") { + t.Errorf("unreachable: %q", why) + } + u.Updates = newBucket() + if _, _, why := u.Gate(); !strings.Contains(why, "not on the bus") { + t.Errorf("no updates key: %q", why) + } +} diff --git a/modules/pacman/cmd/pacman-tools/installed_health.go b/modules/pacman/cmd/pacman-tools/installed_health.go new file mode 100644 index 00000000..bf47c9a8 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/installed_health.go @@ -0,0 +1,32 @@ +package main + +// A node whose packages are over the bus's cap is unhealthy, naming the size (novox/hq ADR 0507 §2, acceptance +// 1840/AC3): `installed.` keeps its last value, which is no longer the node's, and the module's health says +// so until the keeper writes it again and deletes the mark. + +import ( + "encoding/json" + "fmt" +) + +// InstalledHealth is the health of this node's `installed` key: unhealthy while `.not-current` is there, +// with its size and since when; ok false when there is nothing to say (the key is current, or the state cannot +// be read here, which the keeper's own log says). +func (m *Machine) InstalledHealth() (Health, bool) { + if m.States == nil || m.Node == "" { + return Health{}, false + } + e, err := m.States(StateInstalled).Get(NotCurrentKey(m.Node)) + if err != nil { + return Health{Why: "the node's packages on the bus could not be read: " + err.Error()}, true + } + if e == nil { + return Health{}, false + } + var nc NotCurrent + if err := json.Unmarshal(e.Value, &nc); err != nil { + return Health{Why: fmt.Sprintf("installed.%s is marked not current, and its mark is not readable: %v", m.Node, err)}, true + } + return Health{Why: fmt.Sprintf("the node's packages come to %d bytes, over the bus's cap of %d on a value, since %s: "+ + "installed.%s keeps an older list", nc.Bytes, ValueCap, nc.Since.Format("2006-01-02 15:04 MST"), m.Node)}, true +} diff --git a/modules/pacman/cmd/pacman-tools/installed_health_test.go b/modules/pacman/cmd/pacman-tools/installed_health_test.go new file mode 100644 index 00000000..5b3f559f --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/installed_health_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "strings" + "testing" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// fakeReader reads a fakeStore as a verb does. +type fakeReader struct{ *fakeStore } + +func (r fakeReader) History(key string) ([]stdio.StateRevision, error) { + e, _ := r.Get(key) + if e == nil { + return []stdio.StateRevision{}, nil + } + return []stdio.StateRevision{{Key: key, Value: e.Value, Revision: e.Revision, Op: "put", At: r.at[key]}}, nil +} + +// readerOf is a machine's States over fake states. +func readerOf(states *fakeStates) func(string) StateReader { + return func(name string) StateReader { return fakeReader{states.store(name)} } +} + +// 1840/AC3: the health is unhealthy naming the size while the node is marked not current, and says nothing of +// it once the mark is gone. +func TestTheHealthNamesTheSizeWhileTheNodeIsNotCurrent(t *testing.T) { + states := &fakeStates{} + m := machine(nil, 1000) + m.Node, m.States = "node-a", readerOf(states) + if _, said := m.InstalledHealth(); said { + t.Fatal("a current node's health said something") + } + states.store(StateInstalled).Put("node-a.not-current", NotCurrent{Bytes: 300000, Since: t0}) + states.store(StateInstalled).Put("node-b.not-current", NotCurrent{Bytes: 1, Since: t0}) + h, said := m.InstalledHealth() + if !said || h.Healthy || !strings.Contains(h.Why, "300000 bytes") || !strings.Contains(h.Why, "2026-10-24 04:00") { + t.Fatalf("%+v", h) + } + states.store(StateInstalled).Delete("node-a.not-current") + if _, said := m.InstalledHealth(); said { + t.Fatal("the mark is gone and the health still says it") + } +} diff --git a/modules/pacman/cmd/pacman-tools/keeper.go b/modules/pacman/cmd/pacman-tools/keeper.go new file mode 100644 index 00000000..7c6eb981 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/keeper.go @@ -0,0 +1,639 @@ +package main + +// The keeper (novox/hq ADR 0507 §1-§4; ADR 0508): the module's process on every node, under an account of its +// own with a bus account of its own (ADR 0436 §1). It keeps the node's packages in the state `installed` and what +// an upgrade would change in `updates`, and says each change of the packages as `package.changed`. It reads the +// machine at its start and whenever the machine may have changed, never on a timer but the one daily read of +// `updates` ADR 0507 §4 allows: +// +// - pacman's database lock released (an inotify watch on its directory: the lock file deleted) ends a +// transaction, whoever ran it: `installed` and then `updates` are read again; +// - the mirror list rewritten (reflector writes /etc/pacman.d/mirrorlist): `updates` is read again; +// - a day after `updates` was last read: it is read again, since the repositories change what an upgrade +// would do without any transaction on the node. +// +// A key is written only when what it holds changed (ADR 0289 §1). A value over the bus's cap is not written: +// the key keeps its last value and `.not-current` says the size and since when, written once and deleted +// once the node's packages are current again (ADR 0507 §2). +// +// Everything the keeper touches is injected (its states, its events, the machine, the clock, the watch), so the +// loop is tested without a machine or a bus. One goroutine runs it all: the watch's signals, the clock, and every +// change a hook watches are handled one at a time, in the order they came. +// +// # Hooks (the upgrade parts of ADR 0508 join here) +// +// Hooks are where further work joins the loop, each run on the loop's goroutine: +// +// - Start: once, after the first reads of `installed` and `updates`, before anything else; +// - AfterTransaction: after every released lock, once `installed` and `updates` were read again, with what the +// transaction changed (nil when nothing); +// - Changes: each a state and a key pattern (`*` one name, `**` the rest) watched for the life of the keeper; +// its Handle sees the current values first (Current set), then every put and delete in order; +// - Wakes: each a function the keeper calls at its start and again at the time it last answered; it answers when +// it next wants to be called (a zero time: not again). It is how a hook keeps a time (a round's opening, a +// stall) without polling. +// +// A hook's error is logged and the keeper goes on: a fault in one part never stops the view. keeperHooks builds +// the hooks the keeper runs with, once connected; it is the one place a part is added. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/process" +) + +// ValueCap is the bus's cap on one value of a state (ADR 0201 §14): the mesh's bound, not the module's. +const ValueCap = 256 << 10 + +// Where pacman keeps its database and its lock, and where the mirror list is. +const ( + PacmanDBDir = "/var/lib/pacman" + LockName = "db.lck" + MirrorDir = "/etc/pacman.d" + MirrorName = "mirrorlist" +) + +// UpdatesEvery is how long after the last read of `updates` it is read again with nothing else asking (ADR 0507 +// §4: once a day). +const UpdatesEvery = 24 * time.Hour + +// Store is one state as the keeper uses it: a process state on the bus (process.State), or a fake in a test. +type Store interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) + Put(key string, value any) (uint64, error) + Delete(key string) error + Keys() ([]string, error) + Watch(key string, handle func(stdio.StateChange) error) error +} + +// Clock is the time as the keeper sees it. +type Clock interface { + Now() time.Time + // After fires once, d from now. + After(d time.Duration) <-chan time.Time +} + +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } +func (realClock) After(d time.Duration) <-chan time.Time { return time.After(d) } + +// Signal is what the machine's watch saw. +type Signal int + +const ( + // LockReleased: pacman's database lock was deleted, a transaction (or a database sync) ended. + LockReleased Signal = iota + 1 + // MirrorsRewritten: the mirror list was written or replaced. + MirrorsRewritten +) + +func (s Signal) String() string { + switch s { + case LockReleased: + return "the lock released" + case MirrorsRewritten: + return "the mirror list rewritten" + } + return fmt.Sprintf("signal %d", int(s)) +} + +// Watcher is the machine's watch: its signals, until it is closed or fails (the channel closes). +type Watcher interface { + Signals() <-chan Signal + Close() error +} + +// Transaction is a released lock as AfterTransaction sees it: when, and what it changed in `installed` +// (nil when it changed nothing, or the value could not be written). +type Transaction struct { + At time.Time + Changed *PackageChanged +} + +// StateHook is a watch on one of the module's states for the life of the keeper. +type StateHook struct { + State string + Key string + Handle func(k *Keeper, c stdio.StateChange) error +} + +// Wake is called at the keeper's start and then at the time it last answered; it answers when next. +type Wake func(k *Keeper, now time.Time) time.Time + +// Hooks are the parts that join the keeper's loop (see the file's comment). +type Hooks struct { + Start []func(k *Keeper) error + AfterTransaction []func(k *Keeper, t Transaction) error + Changes []StateHook + Wakes []Wake +} + +// keeperHooks is the hooks the keeper runs with, built once the keeper is connected: the one place a part of +// the keeper is added. Today: the upgrades of ADR 0508 (upgrades.go, record.go, transaction.go, rounds.go), for +// this node. +// +// - Start: the package manager's log read into `runs` once (ImportLog), records past record-days pruned +// (PruneRecords), and every unfinished run carried on (Follow), a run a keeper that went away left included; +// - AfterTransaction: every unfinished run carried on (Follow): a released lock may be a run's transaction ending; +// - Changes: `asks` at `.*`, each change handed to TakeAsk; `rounds` and `runs`, every change, to the +// round's step (RoundStep): a node starts the moment the node before it passed; +// - Wakes: PruneRecords once a day; the round's step at the time it answered (the Saturday's opening, a pass +// whose answer is read again, a refused run tried again). +func keeperHooks(k *Keeper) Hooks { + root := k.Root + if root == nil { + root = newRootActs() + } + u := &Upgrades{Node: k.Node, Config: k.Config, Machine: k.Machine, Root: root, Events: k.Events, + Installed: k.State(StateInstalled), Updates: k.State(StateUpdates), Runs: k.State(StateRuns), + Records: k.State(StateRecords), Rounds: k.State(StateRounds), Asks: k.State(StateAsks), AskRecord: k.AskRecord, + Check: k.Check} + k.upgrades = u + const roundWake = 1 // the round's step is the second wake + roundStep := func(k *Keeper, now time.Time) time.Time { + next, err := u.RoundStep() + if err != nil { + k.logf("[pacman] keeper: the round: %v", err) + } + return next + } + // onRoundChange takes the round's step on a change of `rounds` or `runs`, and keeps the time it answers. The + // current values handed at the watch's start are not each a step: the wake at the start takes one. + onRoundChange := func(k *Keeper, c stdio.StateChange) error { + if c.Current { + return nil + } + k.wakeAt(roundWake, roundStep(k, k.Clock.Now())) + return nil + } + return Hooks{ + Start: []func(*Keeper) error{ + func(k *Keeper) error { + n, err := u.ImportLog() + if n > 0 { + k.logf("[pacman] keeper: %d past upgrades read from the package manager's log into runs", n) + } + return err + }, + func(*Keeper) error { _, err := u.PruneRecords(); return err }, + func(*Keeper) error { return u.Follow() }, + }, + AfterTransaction: []func(*Keeper, Transaction) error{ + func(*Keeper, Transaction) error { return u.Follow() }, + }, + Changes: []StateHook{ + {State: StateAsks, Key: k.Node + ".*", Handle: func(_ *Keeper, c stdio.StateChange) error { + return u.TakeAsk(c) + }}, + {State: StateRounds, Key: "**", Handle: onRoundChange}, + {State: StateRuns, Key: "**", Handle: onRoundChange}, + }, + Wakes: []Wake{ + func(k *Keeper, now time.Time) time.Time { + if !now.Equal(k.started) { + if _, err := u.PruneRecords(); err != nil { + k.logf("[pacman] keeper: pruning records: %v", err) + } + } + return now.Add(UpdatesEvery) + }, + roundStep, + }, + } +} + +// newRootActs is the client of the module's root helper (ADR 0510), which carries out the acts that need root, +// at the socket the manifest names to the keeper; with none named, every act is refused, named. +var newRootActs = func() RootActs { + if sock := os.Getenv(EnvRootSocket); sock != "" { + return rootClient{Socket: sock} + } + return noRoot{} +} + +type noRoot struct{} + +const noRootSaid = "the keeper was started without " + EnvRootSocket + ", the root helper's socket" + +func (noRoot) Transaction(kind, run string, ignore []string) error { + return errors.New(noRootSaid + ": no transaction can start") +} + +func (noRoot) Journal(unit string, n int) ([]string, error) { + return nil, errors.New(noRootSaid + ": no journal can be read") +} + +func (noRoot) Rollback(run string, plan []RollbackItem) error { + return errors.New(noRootSaid + ": no rollback can start") +} + +func (noRoot) Maps() ([]Mapped, error) { + return nil, errors.New(noRootSaid + ": no process's maps can be read") +} + +// Keeper is the module's process on one node. +type Keeper struct { + Node string + Config KeeperConfig + Machine *Machine + State func(name string) Store + // Events emits the keeper's events; package.changed carries the id `installed..`, the bus's + // dedup id, so the same change said twice is dropped. + Events EventSink + Clock Clock + Watcher Watcher + // Locked says pacman's lock is held now: a released lock followed at once by another transaction is read + // at that one's end. + Locked func() bool + // DBChanged is when the local package database last changed, the time of a value read at the start. + DBChanged func() time.Time + Logf func(format string, a ...any) + // Hooks are the parts joining the loop; MakeHooks, when set, builds them once the keeper is connected + // (its State and Events set), replacing Hooks. + Hooks Hooks + MakeHooks func(k *Keeper) Hooks + // AskRecord reads the operator channel's record of one of the module's asks (process.Conn.Ask). + AskRecord func(id string) (*stdio.StateEntry, error) + // Root carries out the acts that need root; nil is the root helper's client (newRootActs). Check is the + // after-upgrade check; nil is the machine's (AfterCheck). A test sets both. + Root RootActs + Check func(run Run, record *Record) Check + + upgrades *Upgrades + + started time.Time + + nextUpdates time.Time + wakes []time.Time +} + +func (k *Keeper) logf(format string, a ...any) { + if k.Logf != nil { + k.Logf(format, a...) + } +} + +type change struct { + hook StateHook + c stdio.StateChange +} + +// Run reads the machine into the bus and then follows it until ctx ends or the watch fails. +func (k *Keeper) Run(ctx context.Context) error { + if k.MakeHooks != nil { + k.Hooks = k.MakeHooks(k) + } + k.started = k.Clock.Now() + at := k.started + if k.DBChanged != nil { + if t := k.DBChanged(); !t.IsZero() { + at = t + } + } + k.refreshInstalled(at) + k.refreshUpdates() + for _, h := range k.Hooks.Start { + if err := h(k); err != nil { + k.logf("[pacman] keeper: a start hook: %v", err) + } + } + changes := make(chan change, 256) + for _, h := range k.Hooks.Changes { + h := h + err := k.State(h.State).Watch(h.Key, func(c stdio.StateChange) error { + if c.Current { + // the current values are handed over inside Watch, on this goroutine. + k.handle(h, c) + return nil + } + select { + case changes <- change{h, c}: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + if err != nil { + return fmt.Errorf("watching %s %q: %w", h.State, h.Key, err) + } + } + now := k.started + k.wakes = make([]time.Time, len(k.Hooks.Wakes)) + for i, w := range k.Hooks.Wakes { + k.wakes[i] = w(k, now) + } + signals := k.Watcher.Signals() + for { + now = k.Clock.Now() + next := k.nextUpdates + for _, w := range k.wakes { + if !w.IsZero() && w.Before(next) { + next = w + } + } + timer := k.Clock.After(next.Sub(now)) + select { + case <-ctx.Done(): + return ctx.Err() + case s, open := <-signals: + if !open { + return errors.New("the machine's watch ended") + } + k.signal(s) + case c := <-changes: + k.handle(c.hook, c.c) + case <-timer: + k.due() + } + } +} + +// wakeAt sets when wake i is called next, from a change a hook handled; before the wakes are first called +// (the current values handed at a watch's start) there is nothing to set. +func (k *Keeper) wakeAt(i int, at time.Time) { + if i < len(k.wakes) { + k.wakes[i] = at + } +} + +func (k *Keeper) handle(h StateHook, c stdio.StateChange) { + if err := h.Handle(k, c); err != nil { + k.logf("[pacman] keeper: handling %s %s: %v", h.State, c.Key, err) + } +} + +// signal acts on what the machine's watch saw. +func (k *Keeper) signal(s Signal) { + switch s { + case LockReleased: + if k.Locked != nil && k.Locked() { + return + } + at := k.Clock.Now() + changed := k.refreshInstalled(at) + k.refreshUpdates() + for _, h := range k.Hooks.AfterTransaction { + if err := h(k, Transaction{At: at, Changed: changed}); err != nil { + k.logf("[pacman] keeper: after a transaction: %v", err) + } + } + case MirrorsRewritten: + k.refreshUpdates() + } +} + +// due runs what the clock has reached: the daily read of `updates`, and every wake whose time came. +func (k *Keeper) due() { + now := k.Clock.Now() + if !now.Before(k.nextUpdates) { + k.refreshUpdates() + } + for i, w := range k.wakes { + if !w.IsZero() && !now.Before(w) { + k.wakes[i] = k.Hooks.Wakes[i](k, now) + } + } +} + +// refreshInstalled reads the node's packages and writes `installed.` when they differ from the key, +// emitting `package.changed` with what changed. It answers that change, nil when nothing was written. +func (k *Keeper) refreshInstalled(at time.Time) *PackageChanged { + pkgs, skipped, err := k.Machine.ReadInstalled() + if err != nil { + k.logf("[pacman] keeper: reading the installed packages: %v", err) + return nil + } + if skipped > 0 { + k.logf("[pacman] keeper: %d lines of pacman's list were not a package and were left out", skipped) + } + st := k.State(StateInstalled) + var before InstalledValue + entry, err := st.Get(k.Node) + if err != nil { + k.logf("[pacman] keeper: reading installed.%s: %v", k.Node, err) + return nil + } + if entry != nil { + if err := json.Unmarshal(entry.Value, &before); err != nil { + k.logf("[pacman] keeper: installed.%s holds no package list, so it is written whole: %v", k.Node, err) + entry = nil + } + } + if entry != nil && samePackages(before.Packages, pkgs) { + k.current() + return nil + } + value := InstalledValue{At: at.UTC(), Packages: pkgs} + data, err := json.Marshal(value) + if err != nil { + k.logf("[pacman] keeper: %v", err) + return nil + } + if len(data) > ValueCap { + k.notCurrent(len(data), at) + return nil + } + rev, err := st.Put(k.Node, value) + if err != nil { + k.logf("[pacman] keeper: writing installed.%s: %v", k.Node, err) + return nil + } + k.current() + c := DiffInstalled(before.Packages, pkgs) + if c.Empty() { + return nil + } + ev := &PackageChanged{Node: k.Node, At: at.UTC(), Added: c.Added, Removed: c.Removed, Changed: c.Changed} + id := fmt.Sprintf("%s.%s.%d", StateInstalled, k.Node, rev) + if err := k.Events.Emit(EventPackageChanged, *ev, stdio.EmitOptions{MessageID: id}); err != nil { + k.logf("[pacman] keeper: emitting %s for installed.%s at revision %d: %v", EventPackageChanged, k.Node, rev, err) + } + return ev +} + +func samePackages(a, b []InstalledPackage) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// notCurrent marks the node's packages as not current, once: the first size and moment stay until it is current +// again. +func (k *Keeper) notCurrent(size int, at time.Time) { + st := k.State(StateInstalled) + key := NotCurrentKey(k.Node) + k.logf("[pacman] keeper: the node's packages come to %d bytes, over the bus's cap of %d: installed.%s keeps its last value", size, ValueCap, k.Node) + if e, err := st.Get(key); err != nil || e != nil { + if err != nil { + k.logf("[pacman] keeper: reading %s: %v", key, err) + } + return + } + if _, err := st.Put(key, NotCurrent{Bytes: size, Since: at.UTC()}); err != nil { + k.logf("[pacman] keeper: writing %s: %v", key, err) + } +} + +// current deletes the not-current mark, when there is one: the key holds the node's packages again. +func (k *Keeper) current() { + st := k.State(StateInstalled) + key := NotCurrentKey(k.Node) + e, err := st.Get(key) + if err != nil { + k.logf("[pacman] keeper: reading %s: %v", key, err) + return + } + if e == nil { + return + } + if err := st.Delete(key); err != nil { + k.logf("[pacman] keeper: deleting %s: %v", key, err) + } +} + +// refreshUpdates reads what a full upgrade would change and writes `updates.` when the list or reachable +// changed. A failed read keeps the last list and says reachable false, never an empty list in its place. +func (k *Keeper) refreshUpdates() { + k.nextUpdates = k.Clock.Now().Add(UpdatesEvery) + list, err := k.Machine.UpdateList() + st := k.State(StateUpdates) + entry, gerr := st.Get(k.Node) + if gerr != nil { + k.logf("[pacman] keeper: reading updates.%s: %v", k.Node, gerr) + return + } + var before UpdatesValue + if entry != nil { + if uerr := json.Unmarshal(entry.Value, &before); uerr != nil { + entry = nil + } + } + next := UpdatesValue{Reachable: true, Packages: list} + if err != nil { + k.logf("[pacman] keeper: reading what an upgrade would change: %v", err) + next = UpdatesValue{Reachable: false, Packages: before.Packages} + } + if next.Packages == nil { + next.Packages = []VersionChange{} + } + if entry != nil && sameUpdates(before, next) { + return + } + if _, err := st.Put(k.Node, next); err != nil { + k.logf("[pacman] keeper: writing updates.%s: %v", k.Node, err) + } +} + +func sameUpdates(a, b UpdatesValue) bool { + if a.Reachable != b.Reachable || len(a.Packages) != len(b.Packages) { + return false + } + for i := range a.Packages { + if a.Packages[i] != b.Packages[i] { + return false + } + } + return true +} + +// The keeper as a process. + +// ReadKeeperConfig reads the keeper's config file, which the mesh writes from the module's config values. +func ReadKeeperConfig(path string) (KeeperConfig, error) { + var c KeeperConfig + if path == "" { + return c, fmt.Errorf("the keeper was started without %s, its config file", EnvConfig) + } + data, err := os.ReadFile(path) + if err != nil { + return c, fmt.Errorf("reading the keeper's config: %w", err) + } + if err := json.Unmarshal(data, &c); err != nil { + return c, fmt.Errorf("the keeper's config at %s is not its JSON: %w", path, err) + } + return c, nil +} + +// keeper is `pacman-tools keeper`: it reads its bus account and its config, watches the machine, connects and +// runs until stopped. It refuses to start naming what is missing, and never logs a password. +func keeper() int { + logf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) } + brokerFile := os.Getenv(EnvBroker) + if brokerFile == "" { + logf("[pacman] keeper was started without %s, its bus account's file", EnvBroker) + return 2 + } + account, err := process.ReadAccount(brokerFile) + if err != nil { + logf("[pacman] keeper: %v", err) + return 1 + } + cfg, err := ReadKeeperConfig(os.Getenv(EnvConfig)) + if err != nil { + logf("[pacman] keeper: %v", err) + return 1 + } + // the watch starts before the first read, so a transaction between the two is not missed. + w, err := WatchMachine(PacmanDBDir, LockName, MirrorDir, MirrorName) + if err != nil { + logf("[pacman] keeper: watching the machine: %v", err) + return 1 + } + defer w.Close() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + k := &Keeper{ + Node: account.Node, Config: cfg, Machine: ThisMachine(), Clock: realClock{}, Watcher: w, Logf: logf, + Locked: func() bool { _, err := os.Stat(filepath.Join(PacmanDBDir, LockName)); return err == nil }, + DBChanged: func() time.Time { return modified(filepath.Join(PacmanDBDir, "local")) }, + MakeHooks: keeperHooks, + } + logf("[pacman] keeper of %s is starting as %s", account.Node, account.User) + if err := runKeeper(ctx, account, k); err != nil && ctx.Err() == nil { + logf("[pacman] keeper stopped: %v", err) + return 1 + } + return 0 +} + +func modified(path string) time.Time { + if fi, err := os.Stat(path); err == nil { + return fi.ModTime() + } + return time.Time{} +} + +// runKeeper connects as the keeper's own account and runs k on that connection until ctx ends. Apart from keeper +// so a test can drive it over a bus of its own. +func runKeeper(ctx context.Context, account process.Account, k *Keeper) error { + conn, err := process.Connect(account) + if err != nil { + return fmt.Errorf("the bus cannot be reached: %w", err) + } + defer conn.Close() + if k.Node == "" { + k.Node = account.Node + } + k.State = func(name string) Store { return conn.State(name) } + k.Events = conn + k.AskRecord = func(id string) (*stdio.StateEntry, error) { return conn.Ask(MessengerAsksBucket, id) } + return k.Run(ctx) +} diff --git a/modules/pacman/cmd/pacman-tools/keeper_bus_test.go b/modules/pacman/cmd/pacman-tools/keeper_bus_test.go new file mode 100644 index 00000000..f337c7e3 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/keeper_bus_test.go @@ -0,0 +1,97 @@ +package main + +// What the keeper puts on the bus is only its named fields (novox/hq ADR 0507 §1; acceptance 1840/AC7): pacman's +// output holding command lines, environment variables and messages leaves none of them in a value or an event, +// and no command the keeper runs names a package, a foreign one least of all. (busshapes_test.go holds every +// shape to its named fields.) + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPacmanOutputHoldingCommandLinesAndEnvironmentPutsNeitherOnTheBus(t *testing.T) { + dir := filepath.Join("testdata", "pacman-output-with-command-lines-and-environment") + read := func(name string) string { + b, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + db := &pacmanDB{sl: read("pacman-Sl"), checkup: Ran{Stdout: read("checkupdates")}} + db.set(read("pacman-Q"), read("pacman-Qeq")) + k, states, ev, clock, w := newTestKeeper(db) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- k.Run(ctx) }() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + if e, _ := states.store(StateUpdates).Get("node-a"); e != nil { + break + } + } + clock.Advance(time.Minute) + w.c <- LockReleased + w.c <- MirrorsRewritten + time.Sleep(20 * time.Millisecond) + cancel() + <-done + + var onBus []string + for _, name := range []string{StateInstalled, StateUpdates} { + s := states.store(name) + keys, _ := s.Keys() + for _, key := range keys { + e, _ := s.Get(key) + onBus = append(onBus, name+"."+key+" "+string(e.Value)) + } + } + events := ev.Take() + if len(events) == 0 { + t.Fatal("the first write said nothing") + } + for _, e := range events { + body, _ := json.Marshal(e.body) + onBus = append(onBus, e.name+" "+string(body)) + } + if len(onBus) < 3 { + t.Fatalf("on the bus: %v", onBus) + } + whole := strings.Join(onBus, "\n") + for _, never := range []string{"=", "sudo", "--noconfirm", "/", "rm", "warning", "hunter2", "Synchronizing", "HOME", "TOKEN", "SECRET"} { + if strings.Contains(whole, never) { + t.Errorf("%q reached the bus:\n%s", never, whole) + } + } + var inst InstalledValue + e, _ := states.store(StateInstalled).Get("node-a") + json.Unmarshal(e.Value, &inst) + if !samePackages(inst.Packages, []InstalledPackage{{"glibc", "2.42-1", false, "core"}, {"yay", "12.0-1", true, RepoForeign}, {"zsh", "5.9-1", true, "extra"}}) { + t.Errorf("the packages are read, the rest left out: %+v", inst.Packages) + } + var up UpdatesValue + e, _ = states.store(StateUpdates).Get("node-a") + json.Unmarshal(e.Value, &up) + if len(up.Packages) != 1 || up.Packages[0] != (VersionChange{"glibc", "2.42-1", "2.42-2"}) { + t.Errorf("updates: %+v", up) + } + + // no command the keeper ran names a package; the foreign one least of all. + db.mu.Lock() + defer db.mu.Unlock() + for _, c := range db.commands { + switch c { + case "pacman -Q", "pacman -Qeq", "pacman -Sl", "checkupdates": + default: + t.Errorf("the keeper ran %q", c) + } + if strings.Contains(c, "yay") { + t.Errorf("a command named the foreign package: %q", c) + } + } +} diff --git a/modules/pacman/cmd/pacman-tools/keeper_hooks_test.go b/modules/pacman/cmd/pacman-tools/keeper_hooks_test.go new file mode 100644 index 00000000..a2f8c13f --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/keeper_hooks_test.go @@ -0,0 +1,172 @@ +package main + +// The upgrade parts joined to the keeper's loop (keeperHooks): the log read into runs and old records pruned at +// the start, an ask for this node taken from `asks`, and the records pruned again a day later. + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func TestTheKeeperRunsTheUpgradePartsAtItsStartOnItsAsksAndDaily(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\n", "") + k, states, _, clock, _ := newTestKeeper(db) + log := capturedLog(t) + k.Machine.ReadFile = func(path string) ([]byte, error) { + if path == PacmanLog { + return []byte(log), nil + } + return nil, errNoFile + } + k.MakeHooks = keeperHooks + k.Logf = t.Logf + k.Config = KeeperConfig{RecordDays: 180} + records := states.store(StateRecords) + records.Put(NodeKey("node-a", "20250101T040000Z"), Record{Node: "node-a", Run: "20250101T040000Z"}) + records.Put(NodeKey("node-b", "20250101T040000Z"), Record{Node: "node-b", Run: "20250101T040000Z"}) + done := Run{Node: "node-a", Run: "20261003T040000Z", Kind: RunUpgrade, From: FromAsk, Ask: "ask-1", + Steps: []Step{{Step: StepRefused, At: t0, Why: "a test"}}} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ended := make(chan error, 1) + go func() { ended <- k.Run(ctx) }() + until := func(what string, ok func() bool) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + if ok() { + return + } + } + t.Fatalf("never: %s", what) + } + until("the log read into runs", func() bool { + keys, _ := states.store(StateRuns).Keys() + return len(keys) > 1 + }) + until("the old record pruned at the start", func() bool { + e, _ := records.Get(NodeKey("node-a", "20250101T040000Z")) + return e == nil + }) + if e, _ := records.Get(NodeKey("node-b", "20250101T040000Z")); e == nil { + t.Error("another node's record was pruned") + } + + // a run an ask started, ended; its ask left behind by a keeper that went away. + states.store(StateRuns).Put(NodeKey("node-a", done.Run), done) + asks := states.store(StateAsks) + asks.Put(NodeKey("node-a", "ask-1"), Ask{ID: "ask-1", Node: "node-a", Act: ActUpgrade, At: t0}) + until("the ask a run names deleted", func() bool { + e, _ := asks.Get(NodeKey("node-a", "ask-1")) + return e == nil + }) + + records.Put(NodeKey("node-a", "20250102T040000Z"), Record{Node: "node-a", Run: "20250102T040000Z"}) + time.Sleep(20 * time.Millisecond) + if e, _ := records.Get(NodeKey("node-a", "20250102T040000Z")); e == nil { + t.Fatal("pruned before a day passed") + } + clock.Advance(UpdatesEvery) + until("the old record pruned a day later", func() bool { + e, _ := records.Get(NodeKey("node-a", "20250102T040000Z")) + return e == nil + }) + cancel() + if err := <-ended; err == nil || !strings.Contains(err.Error(), "canceled") { + t.Errorf("Run: %v", err) + } +} + +// roundKeeper is a keeper of node in a round of the made-up nodes order, its machine's time the keeper's clock. +func roundKeeper(t *testing.T, node string, order []string) (*Keeper, *fakeStates, *fakeClock) { + t.Helper() + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\n", "") + k, states, _, clock, _ := newTestKeeper(db) + k.Node = node + k.Machine.Now = clock.Now + k.MakeHooks = keeperHooks + k.Logf = t.Logf + k.Config = KeeperConfig{RecordDays: 180, RoundStartMinutes: 120, UpgradeOrder: order} + k.AskRecord = func(string) (*stdio.StateEntry, error) { return nil, nil } + return k, states, clock +} + +func until(t *testing.T, what string, ok func() bool) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + if ok() { + return + } + } + t.Fatalf("never: %s", what) +} + +func roundNodeOf(s *fakeStore, round, node string) RoundNode { + var rn RoundNode + if e, _ := s.Get(RoundNodeKey(round, node)); e != nil { + _ = json.Unmarshal(e.Value, &rn) + } + return rn +} + +// 1841/AC6 through the keeper's loop: the first of the order opens the week's round at its start on Saturday +// 04:00 and starts its run in it; the round's parts get the operator channel's reader and the rounds state. +func TestTheFirstNodesKeeperOpensTheRoundAndTakesItsTurn(t *testing.T) { + k, states, _ := roundKeeper(t, "node-a", []string{"node-a", "node-b"}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = k.Run(ctx) }() + rounds := states.store(StateRounds) + round := RoundID(t0) + until(t, "the round opened", func() bool { e, _ := rounds.Get(round); return e != nil }) + until(t, "node-a's run named in the round", func() bool { return roundNodeOf(rounds, round, "node-a").Run != "" }) + if rn := roundNodeOf(rounds, round, "node-b"); rn.Run != "" { + t.Errorf("node-b started in node-a's keeper: %+v", rn) + } +} + +// 1841/AC6 through the keeper's loop: a node waits until the node before it passed its check, and starts on +// the change of `runs` that says so, with no wait for a timer. +func TestTheNextNodesKeeperStartsWhenTheRunBeforeItPassed(t *testing.T) { + k, states, clock := roundKeeper(t, "node-b", []string{"node-a", "node-b"}) + round := RoundID(t0) + rounds, runs := states.store(StateRounds), states.store(StateRuns) + runA := RunID(t0) + rounds.Put(round, Round{Round: round, Opened: t0, OpenedBy: "node-a", Order: []string{"node-a", "node-b"}}) + rounds.Put(RoundNodeKey(round, "node-a"), RoundNode{Round: round, Node: "node-a", Run: runA}) + a := Run{Node: "node-a", Run: runA, Kind: RunUpgrade, From: FromRound, Round: round, + Steps: []Step{{Step: StepRecorded, At: t0}, {Step: StepStarted, At: t0}}} + runs.Put(NodeKey("node-a", runA), a) + clock.Advance(10 * time.Minute) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = k.Run(ctx) }() + time.Sleep(30 * time.Millisecond) + if rn := roundNodeOf(rounds, round, "node-b"); rn.Run != "" { + t.Fatalf("node-b started before node-a passed: %+v", rn) + } + a.Steps = append(a.Steps, Step{Step: StepFinished, At: clock.Now()}, Step{Step: StepCheckedAfter, At: clock.Now(), Check: &Check{Passed: true}}) + runs.Put(NodeKey("node-a", runA), a) + until(t, "node-b's run named in the round", func() bool { return roundNodeOf(rounds, round, "node-b").Run != "" }) +} + +// The pass of a node is answered from the operator channel's record: the keeper hands its reader on. +func TestTheKeeperHandsTheOperatorChannelsReaderToTheRound(t *testing.T) { + k, states, _ := roundKeeper(t, "node-a", nil) + k.State = states.State + h := keeperHooks(k) + if k.upgrades == nil || k.upgrades.AskRecord == nil || k.upgrades.Rounds == nil { + t.Fatalf("the round's parts lack the channel's reader or the rounds state: %+v", k.upgrades) + } + if len(h.Wakes) < 2 { + t.Fatalf("no wake for the round: %d wakes", len(h.Wakes)) + } +} diff --git a/modules/pacman/cmd/pacman-tools/keeper_integration_test.go b/modules/pacman/cmd/pacman-tools/keeper_integration_test.go new file mode 100644 index 00000000..4cb3c2f2 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/keeper_integration_test.go @@ -0,0 +1,216 @@ +//go:build integration + +package main + +// The keeper whole over a real bus (novox/hq ADR 0507 §2, §3, §6; acceptance 1840/AC10; the shape of brother's +// device_integration_test.go): a JetStream server of the test's own, the module's state buckets and its event +// stream, and a fake machine. At its start and after a released lock the node's key and package.changed are on +// the bus, and pacman_packages on another instance reads them from there. Compiled by the merge gate's +// `go vet -tags integration`; run by the merge check, never by hand. + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/process" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +func newTestBus(t *testing.T) string { + t.Helper() + opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), + NoLog: true, NoSigs: true} + s, err := server.NewServer(opts) + if err != nil { + t.Fatalf("a bus for this test could not be made: %v", err) + } + go s.Start() + if !s.ReadyForConnections(30 * time.Second) { + s.Shutdown() + t.Fatal("a bus for this test did not come up within 30s") + } + t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() }) + return s.ClientURL() +} + +// kvReader reads a bucket as a tools bundle's state does (stdio.State): what another instance's verbs see. +type kvReader struct{ kv jetstream.KeyValue } + +func (r kvReader) Get(key string) (*stdio.StateEntry, error) { + e, err := r.kv.Get(context.Background(), key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &stdio.StateEntry{Key: key, Value: json.RawMessage(e.Value()), Revision: e.Revision()}, nil +} + +func (r kvReader) Keys() ([]string, error) { + keys, err := r.kv.Keys(context.Background()) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return []string{}, nil + } + return keys, err +} + +func (r kvReader) History(key string) ([]stdio.StateRevision, error) { + h, err := r.kv.History(context.Background(), key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return []stdio.StateRevision{}, nil + } + if err != nil { + return nil, err + } + out := []stdio.StateRevision{} + for _, e := range h { + op := "put" + if e.Operation() != jetstream.KeyValuePut { + op = "delete" + } + out = append(out, stdio.StateRevision{Key: key, Value: e.Value(), Revision: e.Revision(), Op: op, At: e.Created()}) + } + return out, nil +} + +func TestTheKeeperPutsTheNodesPackagesOnARealBusAndAnotherInstanceReadsThem(t *testing.T) { + url := newTestBus(t) + ctx := context.Background() + nc, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + account := process.Account{URL: url, Node: "node-a", Module: "pacman", User: "node-a.pacman.keeper", Password: "pw"} + buckets := map[string]jetstream.KeyValue{} + for _, name := range States { + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: account.Module + "_" + name, History: 8}) + if err != nil { + t.Fatal(err) + } + buckets[name] = kv + } + declared, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-controller_declared-packages"}) + if err != nil { + t.Fatal(err) + } + declared.Put(ctx, "node-a", []byte(`{"node":"node-a","packages":[{"package":"zsh","modules":["shell"],"absent":false}]}`)) + stream, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "pacman-events", Subjects: []string{"mesh.mod.pacman.event.>"}}) + if err != nil { + t.Fatal(err) + } + + db := &pacmanDB{sl: syncDBs, checkup: Ran{Stdout: "zsh 5.9-1 -> 5.9-2\n"}} + db.set("glibc 2.42-1\nzsh 5.9-1\n", "zsh\n") + signals := make(chan Signal) + k := &Keeper{Config: KeeperConfig{}, Machine: machine(db.runner(), 1000), Clock: realClock{}, + Watcher: fakeWatcher{signals}, Logf: t.Logf, MakeHooks: keeperHooks} + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- runKeeper(runCtx, account, k) }() + + waitKey := func(kv jetstream.KeyValue, key, holds string) string { + t.Helper() + for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if e, err := kv.Get(ctx, key); err == nil && strings.Contains(string(e.Value()), holds) { + return string(e.Value()) + } + } + t.Fatalf("%s never held %q", key, holds) + return "" + } + waitKey(buckets[StateInstalled], "node-a", `"zsh"`) + waitKey(buckets[StateUpdates], "node-a", `"reachable":true`) + + // a transaction: tmux added, zsh upgraded; the lock released. + db.set("glibc 2.42-1\ntmux 3.5-1\nzsh 5.9-2\n", "tmux\nzsh\n") + db.setUpdates(Ran{Status: 2}) + signals <- LockReleased + waitKey(buckets[StateInstalled], "node-a", `"tmux"`) + waitKey(buckets[StateUpdates], "node-a", `"packages":[]`) + + var changes []PackageChanged + for deadline := time.Now().Add(15 * time.Second); len(changes) < 2 && time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + changes = nil + info, err := stream.Info(ctx) + if err != nil { + t.Fatal(err) + } + for seq := uint64(1); seq <= info.State.LastSeq; seq++ { + msg, err := stream.GetMsg(ctx, seq) + if err != nil || msg.Subject != "mesh.mod.pacman.event."+EventPackageChanged { + continue + } + var pc PackageChanged + if err := json.Unmarshal(msg.Data, &pc); err != nil { + t.Fatal(err) + } + changes = append(changes, pc) + } + } + if len(changes) != 2 { + t.Fatalf("package.changed at the start and after the transaction: %+v", changes) + } + last := changes[1] + if last.Node != "node-a" || len(last.Added) != 1 || last.Added[0].Name != "tmux" || len(last.Changed) != 1 || + last.Changed[0] != (VersionChange{"zsh", "5.9-1", "5.9-2"}) || len(last.Removed) != 0 { + t.Errorf("the transaction's package.changed: %+v", last) + } + + // another instance's verbs read what the keeper wrote, asking no machine. + other := machine(func(_ context.Context, name string, args ...string) Ran { + t.Errorf("another instance asked a machine: %s %v", name, args) + return Ran{Status: 99} + }, 1000) + other.Node = "node-b" + other.States = func(name string) StateReader { + if name == DeclaredPackagesState { + return kvReader{declared} + } + return kvReader{buckets[name]} + } + r, err := other.Packages("zsh", 500) + if err != nil { + t.Fatal(err) + } + on := r["nodes"].([]PackageOnNode) + if len(on) != 1 || on[0].Node != "node-a" || on[0].Version != "5.9-2" || strings.Join(on[0].DeclaredBy, ",") != "shell" { + t.Errorf("pacman_packages zsh from another instance: %+v", on) + } + r, err = other.Packages("", 500) + if err != nil { + t.Fatal(err) + } + if v := r["nodes"].([]NodeView); len(v) != 1 || v[0].Packages != 3 || len(v[0].Undeclared) != 1 || v[0].Undeclared[0].Name != "tmux" { + t.Errorf("the view from another instance: %+v", v) + } + u, err := other.UpdatesFor("node-a") + if err != nil { + t.Fatal(err) + } + if v := u["nodes"].([]UpdatesView); len(v) != 1 || !v[0].Reachable || v[0].Count != 0 || v[0].Changed == nil { + t.Errorf("pacman_updates from another instance: %+v", v) + } + + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Errorf("runKeeper ended with %v, want its context's end", err) + } + case <-time.After(10 * time.Second): + t.Fatal("runKeeper did not stop once its context was cancelled") + } +} diff --git a/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go b/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go new file mode 100644 index 00000000..be4304c0 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go @@ -0,0 +1,247 @@ +package main + +// The manifest of the keeper (novox/hq ADR 0507 §1-§3, §5; ADR 0508 §1, §2, §5, §6, §8; acceptance 1841/AC12): +// the module's background work is a process of its own under an account the module makes, with a bus account of +// its own, the module's six states and the nine events it emits; the module's config values land in the +// keeper's config file, which restarts it; the weekly timer and its service are gone (a dropped file or service +// is removed by the node-engine); `upgrade-order` falls back to the mesh-level key of that name. + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// rawManifest is the manifest as written, every field kept. +func rawManifest(t *testing.T) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func strs(v any) []string { + var out []string + l, _ := v.([]any) + for _, x := range l { + s, _ := x.(string) + out = append(out, s) + } + return out +} + +func TestTheWeeklyTimerAndItsServiceAreGone(t *testing.T) { + m := manifest(t) + for _, r := range m.Resources { + id, _ := r["id"].(string) + path, _ := r["path"].(string) + unit, _ := r["unit"].(string) + if id == "weekly-upgrade" || id == "upgrade-timer" || id == "upgrade-service" || + strings.Contains(path, "weekly-upgrade") || strings.Contains(unit, "weekly-upgrade") { + t.Errorf("the weekly upgrade is replaced by the round (ADR 0508 §5), and %v still declares it", id) + } + } + for _, name := range []string{"mesh-pacman-weekly-upgrade.timer", "mesh-pacman-weekly-upgrade.service"} { + if _, err := os.Stat(filepath.Join(moduleDir, "files", name)); err == nil { + t.Errorf("files/%s is still shipped", name) + } + } +} + +func TestTheKeeperIsAProcessUnderItsOwnAccountWithABusAccountOfItsOwn(t *testing.T) { + m := manifest(t) + user := m.resource(t, "keeper-account") + if user["type"] != "user" || user["name"] != "pacman-keeper" || user["shell"] != "/usr/bin/nologin" { + t.Fatalf("the keeper's account: %v", user) + } + k := m.resource(t, "keeper") + if k["type"] != "process" || k["name"] != "pacman-keeper" || k["artifact"] != "tools" || + strings.Join(strs(k["run"]), " ") != "./"+binaryName+" keeper" { + t.Fatalf("the keeper: %v", k) + } + if k["user"] != "pacman-keeper" { + t.Errorf("the keeper runs as its own account, never root (ADR 0508 §8): user %v", k["user"]) + } + env, _ := k["env"].(map[string]any) + if len(env) != 3 || env[EnvBroker] != "${dir:state}/broker-keeper.secret" || env[EnvConfig] != "${dir:state}/keeper.json" || + env[EnvRootSocket] != RootSocketPath { + t.Errorf("the keeper's environment names its account file, its config file and the root helper's socket: %v", env) + } + secrets, _ := rawManifest(t)["own-secrets"].(map[string]any) + broker, _ := secrets["broker-keeper"].(map[string]any) + if broker["path"] != env[EnvBroker] || broker["owner"] != "pacman-keeper" || len(secrets) != 1 { + t.Errorf("the bus account is the keeper's own secret, owned by its account: %v", secrets) + } + state := m.resource(t, "state") + if state["type"] != "directory" || state["place"] != "mesh" || state["unknown"] == nil { + t.Errorf("the state directory: %v", state) + } + order := map[string]int{} + for i, r := range m.Resources { + order[r["id"].(string)] = i + } + if order["keeper-account"] > order["keeper"] || order["state"] > order["keeper-config"] || order["keeper-config"] > order["keeper"] { + t.Errorf("the account, the directory and the config file come before the process: %v", order) + } +} + +func TestTheKeeperDeclaresTheModulesStatesAndEvents(t *testing.T) { + raw := rawManifest(t) + k := manifest(t).resource(t, "keeper") + bus, _ := k["bus"].(map[string]any) + var events []string + for _, e := range Events { + events = append(events, e.Name) + } + if got := strs(bus["state"]); !reflect.DeepEqual(got, States) { + t.Errorf("the keeper's states: %v, want %v", got, States) + } + if got := strs(bus["emits"]); !reflect.DeepEqual(got, events) { + t.Errorf("the keeper's events: %v, want %v", got, events) + } + for key := range bus { + if key != "state" && key != "emits" && key != "asks" { + t.Errorf("the keeper's bus declares %s: it keeps its own states, emits its events and reads its asks' answers, nothing else", key) + } + } + // A pass is answered on the operator's channel: the tools ask, the keeper reads the channel's record of its own + // asks (ADR 0508 §6, ADR 0259, ADR 0436 §1). + if bus["asks"] != true || strings.Join(strs(raw["uses"]), " ") != "operator-channel" { + t.Errorf("the keeper reads its asks' answers and the module uses the operator's channel: %v %v", bus["asks"], raw["uses"]) + } + // The tools read round-start-minutes from the keeper's config file. + for _, a := range manifest(t).Build.Artifacts { + if env, _ := a["env"].(map[string]any); a["name"] == "tools" && env[EnvConfig] != "${dir:state}/keeper.json" { + t.Errorf("the tools bundle is not named the keeper's config file: %v", a["env"]) + } + } + if got := strs(raw["state"]); !reflect.DeepEqual(got, States) { + t.Errorf("the module's states: %v, want %v", got, States) + } + if got := strs(raw["emits"]); !reflect.DeepEqual(got, events) { + t.Errorf("the module's events: %v, want %v", got, events) + } + // The verbs read which packages each node's modules declare; the process reads no other module's state + // (ADR 0507 §5, ADR 0436 §1). + if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{"mesh-controller.declared-packages"}) { + t.Errorf("reads: %v", got) + } +} + +// configValue is the module's config value of that key. +func configValue(t *testing.T, key string) map[string]any { + t.Helper() + config, _ := rawManifest(t)["config"].(map[string]any) + v, _ := config[key].(map[string]any) + if v == nil { + t.Fatalf("no config value %s", key) + } + if d, _ := v["description"].(string); len(d) < 40 { + t.Errorf("%s says what it means: %q", key, d) + } + return v +} + +func TestTheConfigValuesLandInTheKeepersConfigFileWhichRestartsIt(t *testing.T) { + order := configValue(t, "upgrade-order") + if order["type"] != "list" || order["trusted"] != true || order["predefined"] != "upgrade-order" || + !reflect.DeepEqual(order["default"], []any{}) || order["required"] != nil { + t.Errorf("upgrade-order is a trusted list falling back to the mesh-level key, empty by default: %v", order) + } + for key, def := range map[string]float64{"record-days": 180, "round-start-minutes": 120} { + v := configValue(t, key) + if v["type"] != "number" || v["default"] != def || v["minimum"] != float64(1) { + t.Errorf("%s is a number of at least 1, %v by default: %v", key, def, v) + } + } + config, _ := rawManifest(t)["config"].(map[string]any) + if len(config) != 3 { + t.Errorf("config: %v", config) + } + m := manifest(t) + f := m.resource(t, "keeper-config") + if f["type"] != "file" || f["path"] != "${dir:state}/keeper.json" || f["merge"] != "json" || f["trusted"] != true || f["mode"] != "0644" { + t.Fatalf("the keeper's config file: %v", f) + } + if got := strs(f["takes-config"]); strings.Join(got, " ") != "upgrade-order record-days round-start-minutes" { + t.Errorf("the file takes every config value: %v", got) + } + if got := strs(m.resource(t, "keeper")["restart-on"]); strings.Join(got, " ") != "keeper-config" { + t.Errorf("a changed config file restarts the keeper, which reads it at its start: %v", got) + } + // The template is the file a node starts with: the defaults, read as the keeper reads them. + dec := json.NewDecoder(bytes.NewReader([]byte(templateText(t, f)))) + dec.DisallowUnknownFields() + var c KeeperConfig + if err := dec.Decode(&c); err != nil { + t.Fatalf("the keeper cannot read its config file: %v", err) + } + if c.UpgradeOrder == nil || len(c.UpgradeOrder) != 0 || c.RecordDays != 180 || c.RoundStartMinutes != 120 { + t.Errorf("the template holds the defaults: %+v", c) + } +} + +func TestUpgradeOrderIsATrustedListOfTheMesh(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "..", "..", "predefined.json")) + if err != nil { + t.Fatal(err) + } + var keys map[string]map[string]any + if err := json.Unmarshal(raw, &keys); err != nil { + t.Fatal(err) + } + k := keys["upgrade-order"] + if k["type"] != "list" || k["trusted"] != true { + t.Fatalf("predefined upgrade-order: %v", k) + } + if d, _ := k["description"].(string); !strings.Contains(d, "order") || !strings.Contains(d, "round") { + t.Errorf("its description says the nodes' order and the round: %q", d) + } + if k["type"] != configValue(t, "upgrade-order")["type"] { + t.Error("a value falls back only to a key of its own type (ADR 0348 §4)") + } +} + +// 1841/AC17: the keeper's state directory is the mesh's directory for the module (place "mesh", /mesh/pacman), +// never place "." or a placement by default: for this module both resolve under /var/lib/pacman, the package manager's +// own database directory (issue 1841: the node-engine used it as found and reported it unhealthy, and wrote keeper.json +// into it). What the keeper and its root helper keep there is still named through ${dir:state}. +func TestTheStateDirectoryIsOutsideThePackageManagersOwn(t *testing.T) { + m := rawManifest(t) + resources, _ := m["resources"].([]any) + var state map[string]any + for _, x := range resources { + if r, _ := x.(map[string]any); r["id"] == "state" { + state = r + } + } + if state == nil || state["type"] != "directory" { + t.Fatalf("no state directory: %v", state) + } + if state["place"] != "mesh" || state["path"] != nil { + t.Fatalf("the state directory says place %v and path %v; it is placed \"mesh\" with no path, so it resolves to "+ + "/var/lib/mesh/pacman and not into /var/lib/pacman", state["place"], state["path"]) + } + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte("/var/lib/pacman")) { + t.Fatal("the manifest names /var/lib/pacman") + } + for _, want := range []string{"${dir:state}/keeper.json", "${dir:state}/broker-keeper.secret", RootSocketPath} { + if !bytes.Contains(raw, []byte(want)) { + t.Errorf("the manifest no longer names %s", want) + } + } +} diff --git a/modules/pacman/cmd/pacman-tools/keeper_test.go b/modules/pacman/cmd/pacman-tools/keeper_test.go new file mode 100644 index 00000000..21ddf094 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/keeper_test.go @@ -0,0 +1,630 @@ +package main + +// The keeper's start and its lock watch (novox/hq ADR 0507 §2, §3; acceptance 1840/AC1-AC3), over a fake state, +// a fake machine, a fake clock and a fake watch. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// fakeStore is one state in memory, recording every write. +type fakeStore struct { + mu sync.Mutex + name string + values map[string]json.RawMessage + revs map[string]uint64 + at map[string]time.Time + rev uint64 + writes []string // "put ", "delete " + watchers []fakeWatch + now func() time.Time +} + +type fakeWatch struct { + key string + handle func(stdio.StateChange) error +} + +func newFakeStore(name string) *fakeStore { + return &fakeStore{name: name, values: map[string]json.RawMessage{}, revs: map[string]uint64{}, at: map[string]time.Time{}} +} + +func (s *fakeStore) Get(key string) (*stdio.StateEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.values[key] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: key, Value: v, Revision: s.revs[key]}, nil +} + +func (s *fakeStore) write(key string, value any, op string) (uint64, error) { + data, err := json.Marshal(value) + if err != nil { + return 0, err + } + s.mu.Lock() + s.rev++ + s.values[key], s.revs[key], s.at[key] = data, s.rev, s.clock() + s.writes = append(s.writes, op+" "+key) + rev, ws := s.rev, append([]fakeWatch(nil), s.watchers...) + s.mu.Unlock() + for _, w := range ws { + if matchKey(w.key, key) { + w.handle(stdio.StateChange{State: s.name, Key: key, Op: "put", Value: data, Revision: rev}) + } + } + return rev, nil +} + +// clock is when a write lands: the store's own time, which a test sets. +func (s *fakeStore) clock() time.Time { + if s.now != nil { + return s.now() + } + return t0 +} + +func (s *fakeStore) Put(key string, value any) (uint64, error) { return s.write(key, value, "put") } + +func (s *fakeStore) Create(key string, value any) (uint64, error) { + if e, _ := s.Get(key); e != nil { + return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) + } + return s.write(key, value, "create") +} + +func (s *fakeStore) Update(key string, value any, revision uint64) (uint64, error) { + if e, _ := s.Get(key); e == nil || e.Revision != revision { + return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) + } + return s.write(key, value, "update") +} + +func (s *fakeStore) Delete(key string) error { + s.mu.Lock() + delete(s.values, key) + delete(s.revs, key) + s.rev++ + s.writes = append(s.writes, "delete "+key) + rev, ws := s.rev, append([]fakeWatch(nil), s.watchers...) + s.mu.Unlock() + for _, w := range ws { + if matchKey(w.key, key) { + w.handle(stdio.StateChange{State: s.name, Key: key, Op: "delete", Revision: rev}) + } + } + return nil +} + +func (s *fakeStore) Keys() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + keys := []string{} + for k := range s.values { + keys = append(keys, k) + } + sortStrings(keys) + return keys, nil +} + +func (s *fakeStore) Watch(key string, handle func(stdio.StateChange) error) error { + keys, _ := s.Keys() + for _, k := range keys { + if !matchKey(key, k) { + continue + } + e, _ := s.Get(k) + if err := handle(stdio.StateChange{State: s.name, Key: k, Op: "put", Value: e.Value, Revision: e.Revision, Current: true}); err != nil { + return err + } + } + s.mu.Lock() + s.watchers = append(s.watchers, fakeWatch{key, handle}) + s.mu.Unlock() + return nil +} + +// Writes is what was written, in order, and forgets it. +func (s *fakeStore) Writes() []string { + s.mu.Lock() + defer s.mu.Unlock() + w := s.writes + s.writes = nil + return w +} + +// matchKey narrows as the bus does: `*` one name, `**` the rest, empty every key. +func matchKey(pattern, key string) bool { + if pattern == "" { + return true + } + p, k := strings.Split(pattern, "."), strings.Split(key, ".") + for i := range p { + if p[i] == "**" { + return len(k) > i + } + if i >= len(k) || (p[i] != "*" && p[i] != k[i]) { + return false + } + } + return len(p) == len(k) +} + +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +// fakeStates is the module's states in memory. +type fakeStates struct { + mu sync.Mutex + states map[string]*fakeStore +} + +func (f *fakeStates) State(name string) Store { return f.store(name) } + +func (f *fakeStates) store(name string) *fakeStore { + f.mu.Lock() + defer f.mu.Unlock() + if f.states == nil { + f.states = map[string]*fakeStore{} + } + if f.states[name] == nil { + f.states[name] = newFakeStore(name) + } + return f.states[name] +} + +// emitted is the events a keeper emitted. +type emitted struct { + mu sync.Mutex + events []emittedEvent +} + +type emittedEvent struct { + name string + body any + id string +} + +func (e *emitted) Emit(name string, body any, opts ...stdio.EmitOptions) error { + e.mu.Lock() + defer e.mu.Unlock() + id := "" + for _, o := range opts { + id = o.MessageID + } + e.events = append(e.events, emittedEvent{name, body, id}) + return nil +} + +func (e *emitted) Take() []emittedEvent { + e.mu.Lock() + defer e.mu.Unlock() + out := e.events + e.events = nil + return out +} + +// fakeClock moves only when the test moves it; After fires once the clock passes it. +type fakeClock struct { + mu sync.Mutex + now time.Time + timers []fakeTimer +} + +type fakeTimer struct { + at time.Time + c chan time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) After(d time.Duration) <-chan time.Time { + c.mu.Lock() + defer c.mu.Unlock() + t := fakeTimer{c.now.Add(d), make(chan time.Time, 1)} + if d <= 0 { + t.c <- c.now + return t.c + } + c.timers = append(c.timers, t) + return t.c +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) + kept := c.timers[:0] + for _, t := range c.timers { + if !t.at.After(c.now) { + t.c <- c.now + continue + } + kept = append(kept, t) + } + c.timers = kept +} + +// fakeWatcher hands the test's signals to the keeper. +type fakeWatcher struct{ c chan Signal } + +func (w fakeWatcher) Signals() <-chan Signal { return w.c } +func (w fakeWatcher) Close() error { return nil } + +// pacmanDB is a node's package database as pacman answers it: the test changes it between reads. +type pacmanDB struct { + mu sync.Mutex + q, qe string + sl string + checkup Ran + commands []string +} + +func (d *pacmanDB) set(q, qe string) { + d.mu.Lock() + defer d.mu.Unlock() + d.q, d.qe = q, qe +} + +func (d *pacmanDB) setUpdates(r Ran) { + d.mu.Lock() + defer d.mu.Unlock() + d.checkup = r +} + +func (d *pacmanDB) runner() Runner { + return func(_ context.Context, name string, args ...string) Ran { + d.mu.Lock() + defer d.mu.Unlock() + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + d.commands = append(d.commands, line) + switch line { + case "pacman -Q": + return Ran{Stdout: d.q} + case "pacman -Qeq": + return Ran{Stdout: d.qe} + case "pacman -Sl": + return Ran{Stdout: d.sl} + case "checkupdates": + return d.checkup + } + return Ran{Status: 99, Stderr: "unexpected command: " + line} + } +} + +var syncDBs = "core glibc 2.42-1 [installed]\ncore linux 6.17.1-1 [installed]\nextra zsh 5.9-2 [installed: 5.9-1]\n" + + "extra zsh-completions 0.35-1\nextra tmux 3.5-1\nextra-testing zsh 5.9-3\n" + +var t0 = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) + +func newTestKeeper(db *pacmanDB) (*Keeper, *fakeStates, *emitted, *fakeClock, fakeWatcher) { + states, ev, clock := &fakeStates{}, &emitted{}, &fakeClock{now: t0} + w := fakeWatcher{make(chan Signal)} + m := machine(db.runner(), 1000) + k := &Keeper{Node: "node-a", Machine: m, State: states.State, Events: ev, Clock: clock, Watcher: w, + Logf: func(string, ...any) {}} + return k, states, ev, clock, w +} + +func installedOf(t *testing.T, s *fakeStore, key string) InstalledValue { + t.Helper() + e, _ := s.Get(key) + if e == nil { + t.Fatalf("no %s", key) + } + var v InstalledValue + if err := json.Unmarshal(e.Value, &v); err != nil { + t.Fatal(err) + } + return v +} + +// 1840/AC1: at the start every package is written with its version, its reason and its repository or foreign, +// and written again only when it differs from the key. +func TestAtItsStartTheKeeperWritesEveryPackageOnlyWhenItDiffersFromTheKey(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\nlinux 6.17.1-1\nyay 12.0-1\nzsh 5.9-1\n", "linux\nyay\nzsh\n") + k, states, ev, _, _ := newTestKeeper(db) + k.DBChanged = func() time.Time { return t0.Add(-time.Hour) } + k.refreshInstalled(k.DBChanged()) + inst := states.store(StateInstalled) + if w := inst.Writes(); strings.Join(w, ",") != "put node-a" { + t.Fatalf("writes: %v", w) + } + v := installedOf(t, inst, "node-a") + want := []InstalledPackage{ + {"glibc", "2.42-1", false, "core"}, {"linux", "6.17.1-1", true, "core"}, + {"yay", "12.0-1", true, RepoForeign}, {"zsh", "5.9-1", true, "extra"}, + } + if !samePackages(v.Packages, want) || !v.At.Equal(t0.Add(-time.Hour)) { + t.Fatalf("installed.node-a: %+v", v) + } + if e := ev.Take(); len(e) != 1 || len(e[0].body.(PackageChanged).Added) != 4 { + t.Fatalf("the first write adds every package: %+v", e) + } + // a second start over the same database writes nothing and says nothing, whatever the time. + k.refreshInstalled(t0) + if w := inst.Writes(); len(w) != 0 { + t.Errorf("the same packages were written again: %v", w) + } + if e := ev.Take(); len(e) != 0 { + t.Errorf("the same packages were said again: %+v", e) + } +} + +// 1840/AC2: a transaction that adds, removes and changes packages is one package.changed naming the node and +// exactly those; one that changes nothing writes and emits nothing; a lock held again waits for its release. +func TestAReleasedLockSaysExactlyWhatTheTransactionChangedOnce(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\nlinux 6.17.1-1\nzsh 5.9-1\n", "linux\nzsh\n") + k, states, ev, clock, _ := newTestKeeper(db) + k.refreshInstalled(t0) + ev.Take() + states.store(StateInstalled).Writes() + + held := false + k.Locked = func() bool { return held } + db.set("glibc 2.42-1\nlinux 6.17.2-1\ntmux 3.5-1\n", "linux\ntmux\n") + held = true + k.signal(LockReleased) + if w := states.store(StateInstalled).Writes(); len(w) != 0 { + t.Fatalf("read while the lock was held again: %v", w) + } + held = false + clock.Advance(time.Minute) + k.signal(LockReleased) + if w := states.store(StateInstalled).Writes(); strings.Join(w, ",") != "put node-a" { + t.Fatalf("writes: %v", w) + } + e := ev.Take() + if len(e) != 1 || e[0].name != EventPackageChanged { + t.Fatalf("events: %+v", e) + } + got := e[0].body.(PackageChanged) + if got.Node != "node-a" || !got.At.Equal(t0.Add(time.Minute)) || + fmt.Sprint(got.Added) != "[{tmux 3.5-1}]" || fmt.Sprint(got.Removed) != "[{zsh 5.9-1}]" || + fmt.Sprint(got.Changed) != "[{linux 6.17.1-1 6.17.2-1}]" { + t.Fatalf("package.changed: %+v", got) + } + if !strings.HasPrefix(e[0].id, "installed.node-a.") { + t.Errorf("the event's id is the key's revision, so a second emit is dropped: %q", e[0].id) + } + // a transaction that changed nothing (a database sync alone). + k.signal(LockReleased) + if w := states.store(StateInstalled).Writes(); len(w) != 0 { + t.Errorf("nothing changed and the key was written: %v", w) + } + if e := ev.Take(); len(e) != 0 { + t.Errorf("nothing changed and it was said: %+v", e) + } + // a change of reason alone rewrites the key and is no package.changed. + db.set("glibc 2.42-1\nlinux 6.17.2-1\ntmux 3.5-1\n", "glibc\nlinux\ntmux\n") + k.signal(LockReleased) + if w := states.store(StateInstalled).Writes(); len(w) != 1 { + t.Errorf("a changed reason is a changed key: %v", w) + } + if e := ev.Take(); len(e) != 0 { + t.Errorf("a changed reason is no change of a version: %+v", e) + } +} + +// bigDB is a package database whose value is over the bus's cap. +func bigDB(n int) string { + var b strings.Builder + for i := 0; i < n; i++ { + fmt.Fprintf(&b, "package-with-a-long-name-%05d 1.0.0-1\n", i) + } + return b.String() +} + +// 1840/AC3: a value over the bus's cap is not written: the key keeps its last value and `.not-current` +// holds the size and since when, written once; the next write that fits deletes it. +func TestAValueOverTheCapKeepsTheLastAndMarksTheNodeNotCurrent(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\n", "") + k, states, ev, clock, _ := newTestKeeper(db) + k.refreshInstalled(t0) + inst := states.store(StateInstalled) + inst.Writes() + ev.Take() + + db.set(bigDB(5000), "") + clock.Advance(time.Minute) + k.signal(LockReleased) + if w := inst.Writes(); strings.Join(w, ",") != "put node-a.not-current" { + t.Fatalf("writes: %v", w) + } + if v := installedOf(t, inst, "node-a"); len(v.Packages) != 1 { + t.Fatalf("the last value is kept: %d packages", len(v.Packages)) + } + if e := ev.Take(); len(e) != 0 { + t.Errorf("nothing was written and it was said: %+v", e) + } + e, _ := inst.Get("node-a.not-current") + var nc NotCurrent + json.Unmarshal(e.Value, &nc) + if nc.Bytes <= ValueCap || !nc.Since.Equal(t0.Add(time.Minute)) { + t.Fatalf("not-current: %+v", nc) + } + // still over: the mark is written once. + db.set(bigDB(5001), "") + clock.Advance(time.Minute) + k.signal(LockReleased) + if w := inst.Writes(); len(w) != 0 { + t.Errorf("the mark was written again: %v", w) + } + // back under the cap: written, and the mark deleted. + db.set("glibc 2.42-1\nzsh 5.9-1\n", "") + k.signal(LockReleased) + if w := inst.Writes(); strings.Join(w, ",") != "put node-a,delete node-a.not-current" { + t.Errorf("writes: %v", w) + } +} + +// The mark goes too when the node's packages come back to what the key holds: the key is current again. +func TestTheMarkGoesWhenThePackagesComeBackToTheKey(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\n", "") + k, states, _, _, _ := newTestKeeper(db) + k.refreshInstalled(t0) + db.set(bigDB(5000), "") + k.signal(LockReleased) + inst := states.store(StateInstalled) + inst.Writes() + db.set("glibc 2.42-1\n", "") + k.signal(LockReleased) + if w := inst.Writes(); strings.Join(w, ",") != "delete node-a.not-current" { + t.Errorf("writes: %v", w) + } +} + +// The loop: a released lock is read, a hook runs after it with what it changed, a watched state's change reaches +// its hook, and a wake comes at the time it asked. +func TestTheLoopRunsTheHooksOnItsOneGoroutine(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\n", "") + k, states, _, clock, w := newTestKeeper(db) + var mu sync.Mutex + var seen []string + say := func(s string) { mu.Lock(); seen = append(seen, s); mu.Unlock() } + k.Hooks = Hooks{ + Start: []func(*Keeper) error{func(*Keeper) error { say("start"); return nil }}, + AfterTransaction: []func(*Keeper, Transaction) error{func(_ *Keeper, tr Transaction) error { + say(fmt.Sprintf("transaction changed=%v", tr.Changed != nil)) + return nil + }}, + Changes: []StateHook{{State: StateAsks, Key: "node-a.*", Handle: func(_ *Keeper, c stdio.StateChange) error { + say(fmt.Sprintf("%s %s current=%v", c.Op, c.Key, c.Current)) + return nil + }}}, + Wakes: []Wake{func(_ *Keeper, now time.Time) time.Time { + if now.Equal(t0) { + return t0.Add(time.Hour) + } + say("wake " + now.Sub(t0).String()) + return time.Time{} + }}, + } + states.store(StateAsks).Put("node-a.ask-1", Ask{ID: "ask-1"}) + states.store(StateAsks).Put("node-b.ask-2", Ask{ID: "ask-2"}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- k.Run(ctx) }() + waitFor(t, &mu, &seen, "start") + db.set("glibc 2.42-1\nzsh 5.9-1\n", "") + w.c <- LockReleased + waitFor(t, &mu, &seen, "transaction changed=true") + states.store(StateAsks).Put("node-a.ask-3", Ask{ID: "ask-3"}) + waitFor(t, &mu, &seen, "put node-a.ask-3 current=false") + clock.Advance(time.Hour) + waitFor(t, &mu, &seen, "wake 1h0m0s") + cancel() + if err := <-done; err != context.Canceled { + t.Errorf("Run ended with %v", err) + } + mu.Lock() + defer mu.Unlock() + if strings.Join(seen, "|") != "put node-a.ask-1 current=true|start|transaction changed=true|put node-a.ask-3 current=false|wake 1h0m0s" && + strings.Join(seen, "|") != "start|put node-a.ask-1 current=true|transaction changed=true|put node-a.ask-3 current=false|wake 1h0m0s" { + t.Errorf("seen: %v", seen) + } +} + +func waitFor(t *testing.T, mu *sync.Mutex, seen *[]string, want string) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + mu.Lock() + for _, s := range *seen { + if s == want { + mu.Unlock() + return + } + } + mu.Unlock() + } + t.Fatalf("never saw %q", want) +} + +// The watch ending ends the keeper, loudly. +func TestTheKeeperStopsWhenTheWatchEnds(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + k, _, _, _, w := newTestKeeper(db) + close(w.c) + if err := k.Run(context.Background()); err == nil || !strings.Contains(err.Error(), "watch ended") { + t.Errorf("Run: %v", err) + } +} + +// The machine's watch: the lock deleted and the mirror list written or moved in are each a signal; anything else +// in those directories is none. +func TestTheWatchSeesTheLockReleasedAndTheMirrorListRewritten(t *testing.T) { + lockDir, mirrorDir := t.TempDir(), t.TempDir() + w, err := WatchMachine(lockDir, LockName, mirrorDir, MirrorName) + if err != nil { + t.Fatal(err) + } + next := func() Signal { + select { + case s := <-w.Signals(): + return s + case <-time.After(200 * time.Millisecond): + return 0 + } + } + lock := filepath.Join(lockDir, LockName) + os.WriteFile(lock, nil, 0o644) + os.WriteFile(filepath.Join(lockDir, "other"), nil, 0o644) + os.Remove(filepath.Join(lockDir, "other")) + if s := next(); s != 0 { + t.Fatalf("a lock taken, and another file, are no signal: %v", s) + } + os.Remove(lock) + if s := next(); s != LockReleased { + t.Fatalf("the lock deleted: %v", s) + } + os.WriteFile(filepath.Join(mirrorDir, MirrorName), []byte("Server = x\n"), 0o644) + if s := next(); s != MirrorsRewritten { + t.Fatalf("the list written: %v", s) + } + tmp := filepath.Join(mirrorDir, "mirrorlist.new") + os.WriteFile(tmp, []byte("Server = y\n"), 0o644) + if s := next(); s != 0 { + t.Fatalf("another file written: %v", s) + } + os.Rename(tmp, filepath.Join(mirrorDir, MirrorName)) + if s := next(); s != MirrorsRewritten { + t.Fatalf("the list moved in: %v", s) + } + w.Close() + for deadline := time.After(2 * time.Second); ; { + select { + case _, open := <-w.Signals(): + if !open { + return + } + case <-deadline: + t.Fatal("closing the watch did not end its signals") + } + } +} diff --git a/modules/pacman/cmd/pacman-tools/machine.go b/modules/pacman/cmd/pacman-tools/machine.go index 691a19d9..adc85428 100644 --- a/modules/pacman/cmd/pacman-tools/machine.go +++ b/modules/pacman/cmd/pacman-tools/machine.go @@ -22,6 +22,8 @@ import ( "os/exec" "strings" "time" + + stdio "git.novox.be/novox/mesh-sdk/go" ) // Ran is what one command did: its output, its exit status, and why it never ran to an answer. @@ -105,6 +107,17 @@ type Machine struct { ReadFile func(path string) ([]byte, error) Now func() time.Time Sleep func(time.Duration) + // Node is the node this bundle runs on (the runtime's MESH_NODE), and States reads the module's states and + // those it reads (the manifest's `reads`), for the verbs that answer from the bus (novox/hq ADR 0507 §6). + Node string + States func(name string) StateReader +} + +// StateReader is a state as a verb reads it: stdio.State, or a fake in a test. +type StateReader interface { + Get(key string) (*stdio.StateEntry, error) + Keys() ([]string, error) + History(key string) ([]stdio.StateRevision, error) } // ThisMachine is the machine the runtime launched this bundle on. @@ -117,7 +130,8 @@ func ThisMachine() *Machine { if account == "" { account = user } - return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep} + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep, + Node: os.Getenv("MESH_NODE"), States: func(name string) StateReader { return stdio.State(name) }} } // Out runs a command that only reads, and fails with what went wrong named. diff --git a/modules/pacman/cmd/pacman-tools/main.go b/modules/pacman/cmd/pacman-tools/main.go index c4d7136a..e10ccd46 100644 --- a/modules/pacman/cmd/pacman-tools/main.go +++ b/modules/pacman/cmd/pacman-tools/main.go @@ -10,6 +10,7 @@ import ( "context" "fmt" "os" + "regexp" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -20,6 +21,19 @@ const binaryName = "pacman-tools" func bg() context.Context { return context.Background() } func main() { + // `pacman-tools keeper` is the module's process (keeper.go), `root` its root helper (root.go); without a + // word it serves the tools. + if len(os.Args) > 1 { + switch os.Args[1] { + case "keeper": + os.Exit(keeper()) + case "root": + // the root helper (root.go, ADR 0510), and the command of a rollback's unit it starts + os.Exit(rootHelper()) + case "root-rollback": + os.Exit(rootRollback(os.Args[2:])) + } + } // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): pacman. if err := stdio.Serve("", tools(ThisMachine())); err != nil { fmt.Fprintln(os.Stderr, err) @@ -33,8 +47,22 @@ var ( return map[string]any{"type": "integer", "description": fmt.Sprintf("at most this many (default %d, at most %d)", def, most)} } sinceArg = map[string]any{"type": "string", "description": "a day, YYYY-MM-DD"} + nodeArg = map[string]any{"type": "string", "description": "a node's name"} + nodeName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) ) +// nodeOf is the node argument, empty when absent; a node's name is one key token. +func nodeOf(args map[string]any) (string, error) { + n, err := text(args, "node", false) + if err != nil { + return "", err + } + if n != "" && !nodeName.MatchString(n) { + return "", fmt.Errorf("node %q is not a node's name", n) + } + return n, nil +} + func tools(m *Machine) []stdio.Tool { return []stdio.Tool{ { @@ -66,15 +94,22 @@ func tools(m *Machine) []stdio.Tool { }, }, { - Name: "pacman_installed", - Description: "Installed packages with version, why each is installed (explicit or dependency) and whether it is foreign (in no repository); narrowed by name, reason or foreign; with totals.", + Name: "pacman_installed", + Description: "A node's installed packages, from the bus (ADR 0507 §2): version, why each is installed (explicit or dependency), its repository, " + + "or foreign (in no repository); narrowed by name, reason or foreign; with totals, the time of the transaction they describe and whether they are current. " + + "This node's by default. (r)", Input: schema(map[string]any{ + "node": nodeArg, "match": map[string]any{"type": "string", "description": "only names holding this"}, "reason": map[string]any{"type": "string", "enum": []string{"explicit", "dependency"}}, "foreign": map[string]any{"type": "boolean", "description": "only foreign packages"}, "limit": limitArg(5000, 20000), }), Run: func(args map[string]any) (any, error) { + node, err := nodeOf(args) + if err != nil { + return nil, err + } match, err := text(args, "match", false) if err != nil { return nil, err @@ -94,7 +129,29 @@ func tools(m *Machine) []stdio.Tool { if err != nil { return nil, err } - return m.Installed(match, reason, foreign, n) + return m.InstalledFrom(node, match, reason, foreign, n) + }, + }, + { + Name: "pacman_packages", + Description: "Every node's packages at once, from the bus (ADR 0507 §6): with package, that package on every node (version, reason, repository " + + "or foreign, the modules declaring it); without, per node its counts, the packages installed explicitly that no module assigned to it declares, " + + "and its foreign packages apart, then the packages whose versions differ between nodes and the packages on some nodes only. " + + "A node whose packages are over the bus's cap is marked not current. No machine is asked. (r)", + Input: schema(map[string]any{ + "package": map[string]any{"type": "string", "description": "one package, on every node"}, + "limit": limitArg(500, 20000), + }), + Run: func(args map[string]any) (any, error) { + p, err := text(args, "package", false) + if err != nil { + return nil, err + } + n, err := whole(args, "limit", 500, 1, 20000) + if err != nil { + return nil, err + } + return m.Packages(p, n) }, }, { @@ -126,18 +183,29 @@ func tools(m *Machine) []stdio.Tool { }, }, { - Name: "pacman_updates", - Description: "What a full upgrade would change, each package from and to (checkupdates: the repositories are asked into a copy of their databases, so asking never sets up a partial upgrade). Needs the network.", - Input: schema(map[string]any{}), - Run: func(map[string]any) (any, error) { return m.Updates() }, + Name: "pacman_updates", + Description: "What a full upgrade would change, from the bus (ADR 0507 §4), on one node or on every node: each package from and to, whether the " + + "node reached its mirrors (false: the list is the last it could confirm), and when the node's list last changed. The keeper reads it with " + + "checkupdates at its start, after every transaction, after every mirror refresh and once a day. (r)", + Input: schema(map[string]any{"node": nodeArg}), + Run: func(args map[string]any) (any, error) { + node, err := nodeOf(args) + if err != nil { + return nil, err + } + return m.UpdatesFor(node) + }, }, { Name: "pacman_upgrade", - Description: "Start a full system upgrade (pacman -Syu --noconfirm, through sudo -n) as a transient unit of its own that outlives the call, " + - "answering at once with the unit and the distribution's news since the last upgrade — read the news first. Given that unit, " + - "answer whether it is running, finished and succeeded, with the tail of its log.", + Description: "Ask for a full system upgrade of a node (this one unless named): the ask is written to the bus, where that node's keeper " + + "takes it and runs it as a run: the package record, the kernel check, the transaction as a unit of its own, and the after-upgrade " + + "check (ADR 0508 §8). Answers at once with the ask and the distribution's news since the last upgrade — read the news first; " + + "pacman_upgrades with the node shows the run that names the ask. Given a transaction unit, answers whether it is running, " + + "finished and succeeded, with the tail of its log.", Input: schema(map[string]any{ - "unit": map[string]any{"type": "string", "description": "a unit this tool started, to read how it goes (optional)"}, + "node": map[string]any{"type": "string", "description": "the node to upgrade (default: this one)"}, + "unit": map[string]any{"type": "string", "description": "a transaction unit, to read how it goes (optional)"}, "lines": limitArg(60, 400), }), Run: func(args map[string]any) (any, error) { @@ -149,15 +217,132 @@ func tools(m *Machine) []stdio.Tool { if err != nil { return nil, err } - return m.Upgrade(unit, n) + if unit != "" { + return m.UpgradeStatus(unit, n) + } + node, err := text(args, "node", false) + if err != nil { + return nil, err + } + if node == "" { + node = thisNode() + } + a, err := AskUpgrade(upgradeState(StateAsks), node, m.Now()) + if err != nil { + return nil, err + } + return map[string]any{"asked": a, "follow": "pacman_upgrades with node " + node + " lists the run that names this ask", "news": m.News("")}, nil }, }, { Name: "pacman_upgrade_health", - Description: "Whether the weekly full upgrade (Saturday 04:00 local time, ADR 0477) last passed, as the module's health check: unhealthy " + - "with the exit and the last error of the journal when it failed, healthy while it runs, after it passed and before it ever ran. (r)", + Description: "This node's upgrades, read from the bus, as the module's health check (ADR 0508 §11): unhealthy while no upgrade-order " + + "is set, while the node's latest run is refused, failed or did not pass its after-upgrade check, hung past two hours, or held a " + + "kernel, and while the week's round stands still at this node or at the node it waits for, naming it and since when. (r)", Input: schema(map[string]any{}), - Run: func(map[string]any) (any, error) { return m.UpgradeHealth(), nil }, + Run: func(map[string]any) (any, error) { + return m.UpgradeHealth(), nil + }, + }, + { + Name: "pacman_upgrades", + Description: "Every full upgrade and rollback of every node, from the bus (ADR 0508 §1): each run's node, kind, what started it, " + + "its round, when it began and ended, its outcome (refused, finished, failed or running) and whether its after-upgrade check passed; " + + "narrowed to a node and to days, newest first. With a run, that run whole, every step with its time: the node's named, else this one's.", + Input: schema(map[string]any{ + "node": map[string]any{"type": "string", "description": "only this node's runs"}, + "from": sinceArg, + "until": map[string]any{"type": "string", "description": "a day, YYYY-MM-DD, the last one listed"}, + "run": map[string]any{"type": "string", "description": "one run, by its id: of node, else of this node"}, + "limit": limitArg(50, 1000), + }), + Run: func(args map[string]any) (any, error) { + var v [4]string + for i, k := range []string{"node", "from", "until", "run"} { + s, err := text(args, k, false) + if err != nil { + return nil, err + } + v[i] = s + } + n, err := whole(args, "limit", 50, 1, 1000) + if err != nil { + return nil, err + } + if v[3] != "" && v[0] == "" { + // An address naming the machine has its node taken out of the arguments (the console): + // the run is this machine's (issue 1841). + v[0] = thisNode() + } + return ListUpgrades(upgradeState(StateRuns), v[0], v[1], v[2], v[3], n) + }, + }, + { + Name: "pacman_rollback", + Description: "Return a node to the package record a run took (ADR 0508 §7). Without apply it says what that would change: each package " + + "from its version now to the record's and where that version comes from (the node's package cache, the Arch Linux Archive's copy " + + "of that exact version, or the archive's repository on the record's date), the packages installed since (removed only with " + + "remove_added), and no foreign package. With apply it asks the node's keeper to carry it out as a run of its own, followed by " + + "the after-upgrade check; pacman_upgrades follows it. This node's by default.", + Input: schema(map[string]any{ + "node": nodeArg, + "run": map[string]any{"type": "string", "description": "the run whose record to return to (pacman_upgrades lists them)"}, + "remove_added": map[string]any{"type": "boolean", "description": "also remove the packages installed since the record"}, + "apply": map[string]any{"type": "boolean", "description": "carry it out"}, + }, "run"), + Run: func(args map[string]any) (any, error) { + node, err := nodeOf(args) + if err != nil { + return nil, err + } + run, err := text(args, "run", true) + if err != nil { + return nil, err + } + if !nodeName.MatchString(run) { + return nil, fmt.Errorf("run %q is not a run's id", run) + } + remove, err := flag(args, "remove_added") + if err != nil { + return nil, err + } + apply, err := flag(args, "apply") + if err != nil { + return nil, err + } + return m.Rollback(node, run, remove, apply) + }, + }, + { + Name: "pacman_round", + Description: "Where the week's upgrade rounds stand (ADR 0508 §5, §6): each round's order and opening, and every node's state in it: " + + "waiting (for the node named), turn, stalled (its turn came longer than round-start-minutes ago), running, passed, failed " + + "(the round stops there) or refused, with since when. With pass, a node and a reason, asks the operator on their channel to let " + + "the round go on past that node; it goes on only on the operator's Approve.", + Input: schema(map[string]any{ + "round": map[string]any{"type": "string", "description": "one round, as 2026-W43 (default: the newest)"}, + "pass": map[string]any{"type": "string", "description": "a node to ask the operator to pass"}, + "reason": map[string]any{"type": "string", "description": "why the node should be passed: required with pass, and shown to the operator"}, + "limit": limitArg(4, 52), + }), + Run: func(args map[string]any) (any, error) { + var v [3]string + for i, k := range []string{"round", "pass", "reason"} { + s, err := text(args, k, false) + if err != nil { + return nil, err + } + v[i] = s + } + if v[1] != "" { + return AskPass(upgradeState(StateRounds), upgradeState(StateRuns), upgradeState(StateAsks), v[0], v[1], v[2], m.Now()) + } + n, err := whole(args, "limit", 4, 1, 52) + if err != nil { + return nil, err + } + return ListRounds(upgradeState(StateRounds), upgradeState(StateRuns), v[0], m.Now(), roundStartMinutes(), n) + }, }, { Name: "pacman_orphans", @@ -258,10 +443,17 @@ func tools(m *Machine) []stdio.Tool { }, }, { - Name: "pacman_foreign", - Description: "Installed packages that no repository this machine syncs carries (pacman -Qm): built from the AUR or by hand, which the mesh cannot install.", - Input: schema(map[string]any{}), - Run: func(map[string]any) (any, error) { return m.Foreign() }, + Name: "pacman_foreign", + Description: "A node's packages that no repository it syncs carries, from the bus: built from the AUR or by hand, which the mesh neither " + + "upgrades nor removes (ADR 0507 §7). This node's by default. (r)", + Input: schema(map[string]any{"node": nodeArg}), + Run: func(args map[string]any) (any, error) { + node, err := nodeOf(args) + if err != nil { + return nil, err + } + return m.ForeignFrom(node) + }, }, { Name: "pacman_news", diff --git a/modules/pacman/cmd/pacman-tools/manifest_test.go b/modules/pacman/cmd/pacman-tools/manifest_test.go index da7fb4ad..b55f6908 100644 --- a/modules/pacman/cmd/pacman-tools/manifest_test.go +++ b/modules/pacman/cmd/pacman-tools/manifest_test.go @@ -101,53 +101,3 @@ func TestTheReflectorPackageIsDeclaredBeforeTheFileItShips(t *testing.T) { t.Fatalf("a package's file and timer come after the package: %v", order) } } - -// The weekly upgrade (issue 1574, ADR 0477 clause 5): a timer on Saturday 04:00 local time, persistent so a -// node that was off upgrades at its next start, the same moment on every node (no random delay), starting a -// oneshot service that runs the very upgrade pacman_upgrade runs. -func TestTheWeeklyUpgradeIsATimerOnSaturdayFourPersistentRunningTheToolsUpgrade(t *testing.T) { - m := manifest(t) - timer := m.resource(t, "upgrade-timer") - service := m.resource(t, "upgrade-service") - if timer["path"] != "/etc/systemd/system/mesh-pacman-weekly-upgrade.timer" || service["path"] != "/etc/systemd/system/"+WeeklyUnit { - t.Fatalf("paths: %v %v", timer["path"], service["path"]) - } - tt, st := templateText(t, timer), templateText(t, service) - for _, want := range []string{"\nOnCalendar=Sat *-*-* 04:00:00\n", "\nPersistent=true\n", "\nWantedBy=timers.target\n"} { - if !strings.Contains(tt, want) { - t.Errorf("the timer lacks %q:\n%s", strings.TrimSpace(want), tt) - } - } - for _, not := range []string{"RandomizedDelaySec", "OnBootSec", "OnUnitActiveSec", "UTC"} { - if strings.Contains(tt, not) { - t.Errorf("the timer says %s: the same local moment on every node, and no other moment", not) - } - } - if strings.Count(tt, "OnCalendar=") != 1 { - t.Errorf("one moment only:\n%s", tt) - } - if !strings.Contains(st, "\nType=oneshot\n") { - t.Errorf("the service is a oneshot:\n%s", st) - } - if want := "\nExecStart=/usr/bin/pacman " + strings.Join(UpgradeArgs, " ") + "\n"; !strings.Contains(st, want) { - t.Errorf("the service does not run the upgrade pacman_upgrade runs (%q):\n%s", want, st) - } - if strings.Contains(st, "reboot") || strings.Contains(tt, "reboot") || strings.Contains(st, "systemctl") { - t.Error("the mesh restarts no machine for an upgrade") - } - r := m.resource(t, "weekly-upgrade") - if r["type"] != "service" || r["unit"] != "mesh-pacman-weekly-upgrade.timer" || r["state"] != "running" || r["boot"] != "enabled" { - t.Fatalf("the timer runs and is enabled: %v", r) - } - h, _ := r["health"].(map[string]any) - if h["kind"] != "tool" || h["tool"] != "pacman_upgrade_health" { - t.Errorf("the upgrade's outcome is the resource's health: %v", r["health"]) - } - order := map[string]int{} - for i, x := range m.Resources { - order[x["id"].(string)] = i - } - if order["upgrade-service"] > order["weekly-upgrade"] || order["upgrade-timer"] > order["weekly-upgrade"] || order["package"] > order["upgrade-service"] { - t.Errorf("the units are placed before the timer is started: %v", order) - } -} diff --git a/modules/pacman/cmd/pacman-tools/mapped.go b/modules/pacman/cmd/pacman-tools/mapped.go new file mode 100644 index 00000000..64e18af3 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/mapped.go @@ -0,0 +1,173 @@ +package main + +// The after-upgrade check, its second part (novox/hq ADR 0508 §4): the processes still mapping a file the upgrade +// replaced, named by their unit or container and their executable's path, never their command line or +// environment; and whether every DKMS module is built for every installed kernel. Reading another process's +// mappings needs root, so the root helper reads them (ADR 0510's `maps`), with MappedDeleted below. + +import ( + "os" + "path" + "regexp" + "sort" + "strconv" + "strings" +) + +// ProcFS is the process table as MappedDeleted reads it: the machine's /proc, or a stand-in in a test. +type ProcFS interface { + ReadDir(dir string) ([]string, error) + ReadFile(name string) ([]byte, error) + Readlink(name string) (string, error) +} + +// osProc is the machine's own /proc. +type osProc struct{} + +func (osProc) ReadDir(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + return names, err +} +func (osProc) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) } +func (osProc) Readlink(name string) (string, error) { return os.Readlink(name) } + +// replacedPrefixes are where a package's files are: a mapping deleted elsewhere (a temporary file, shared +// memory) is no file an upgrade replaced. +var replacedPrefixes = []string{"/usr/", "/opt/"} + +var containerScope = regexp.MustCompile(`(?:docker|libpod|crio|cri-containerd)-([0-9a-f]{12})[0-9a-f]*\.scope$`) + +// cgroupOwner names a process's unit or container from its cgroup line `0::/system.slice/x.service`. +func cgroupOwner(cgroup string) (unit, container string) { + for _, l := range strings.Split(cgroup, "\n") { + _, p, ok := strings.Cut(l, "::") + if !ok { + continue + } + parts := strings.Split(strings.Trim(p, "/"), "/") + for i := len(parts) - 1; i >= 0; i-- { + if g := containerScope.FindStringSubmatch(parts[i]); g != nil { + return "", g[1] + } + if i > 0 && parts[i-1] == "docker" && len(parts[i]) >= 12 { + return "", parts[i][:12] + } + } + for i := len(parts) - 1; i >= 0; i-- { + if strings.HasSuffix(parts[i], ".service") || strings.HasSuffix(parts[i], ".scope") { + return parts[i], "" + } + } + } + return "", "" +} + +// MappedDeleted reads the process table under procRoot for processes mapping a deleted package file, each named +// once by its unit or container and its executable, sorted. +func MappedDeleted(fs ProcFS, procRoot string) ([]Mapped, error) { + names, err := fs.ReadDir(procRoot) + if err != nil { + return nil, err + } + seen := map[Mapped]bool{} + out := []Mapped{} + for _, name := range names { + if _, err := strconv.Atoi(name); err != nil { + continue + } + dir := path.Join(procRoot, name) + maps, err := fs.ReadFile(dir + "/maps") + if err != nil { + continue // the process ended meanwhile + } + replaced := false + for _, l := range strings.Split(string(maps), "\n") { + if !strings.HasSuffix(l, " (deleted)") { + continue + } + f := strings.Fields(strings.TrimSuffix(l, " (deleted)")) + if len(f) < 6 { + continue + } + for _, p := range replacedPrefixes { + if strings.HasPrefix(f[5], p) { + replaced = true + } + } + } + if !replaced { + continue + } + exe, _ := fs.Readlink(dir + "/exe") + cgroup, _ := fs.ReadFile(dir + "/cgroup") + unit, container := cgroupOwner(string(cgroup)) + m := Mapped{Unit: unit, Container: container, Executable: strings.TrimSuffix(exe, " (deleted)")} + if !seen[m] { + seen[m] = true + out = append(out, m) + } + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.Unit+a.Container != b.Unit+b.Container { + return a.Unit+a.Container < b.Unit+b.Container + } + return a.Executable < b.Executable + }) + return out, nil +} + +// unbuiltDKMS is every DKMS module built for some kernel and not for an installed one its own bound allows. +func (m *Machine) unbuiltDKMS() ([]DKMSBuild, error) { + modules, err := m.DKMSModules() + if err != nil { + return nil, err + } + kernels, err := m.installedKernels() + if err != nil { + return nil, err + } + out := []DKMSBuild{} + for _, mod := range modules { + if len(mod.Kernels) == 0 { + continue // added and never built: not a module this machine uses + } + bound := m.dkmsBound(mod) + for _, k := range kernels { + if contains(mod.Kernels, k) { + continue + } + if held, _ := heldBy(bound, k); held != "" { + continue + } + out = append(out, DKMSBuild{Module: mod.Module, Version: mod.Version, Kernel: k}) + } + } + return out, nil +} + +// afterCheckII fills the check's second part and judges it: a DKMS module not built for a kernel fails it; +// a process still mapping a replaced file is said and does not. +func (u *Upgrades) afterCheckII(c *Check) { + if u.Root == nil { + c.MappedError = "no root helper to read the processes' mapped files" + } else if mapped, err := u.Root.Maps(); err != nil { + c.MappedError = err.Error() + } else { + c.Mapped = mapped + } + unbuilt, err := u.Machine.unbuiltDKMS() + if err != nil { + c.DKMSError = err.Error() + c.Passed = false + return + } + c.Unbuilt = unbuilt + if len(unbuilt) > 0 { + c.Passed = false + } +} diff --git a/modules/pacman/cmd/pacman-tools/mapped_test.go b/modules/pacman/cmd/pacman-tools/mapped_test.go new file mode 100644 index 00000000..dc5f5574 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/mapped_test.go @@ -0,0 +1,131 @@ +package main + +// The after-upgrade check, its second part (novox/hq ADR 0508 §4; acceptance 1841/AC5): processes still mapping a +// replaced file, by unit or container and executable, never a command line or environment; DKMS built for every +// installed kernel. + +import ( + "errors" + "strings" + "testing" +) + +// standInProc is a process table in memory; it records every file read, so nothing else is read. +type standInProc struct { + files map[string]string + links map[string]string + read []string +} + +func (p *standInProc) ReadDir(dir string) ([]string, error) { + seen := map[string]bool{} + var names []string + for f := range p.files { + if rest, ok := strings.CutPrefix(f, dir+"/"); ok { + n, _, _ := strings.Cut(rest, "/") + if !seen[n] { + seen[n] = true + names = append(names, n) + } + } + } + return names, nil +} + +func (p *standInProc) ReadFile(name string) ([]byte, error) { + p.read = append(p.read, name) + if s, ok := p.files[name]; ok { + return []byte(s), nil + } + return nil, errNoFile +} + +func (p *standInProc) Readlink(name string) (string, error) { + if s, ok := p.links[name]; ok { + return s, nil + } + return "", errNoFile +} + +const libcDeleted = "7f2a1c000000-7f2a1c028000 r--p 00000000 103:02 5513 /usr/lib/libc.so.6 (deleted)\n" + +func TestAProcessMappingAReplacedFileIsNamedByItsUnitOrContainerAndExecutableOnly(t *testing.T) { + p := &standInProc{files: map[string]string{ + "/proc/1/maps": libcDeleted, + "/proc/1/cgroup": "0::/init.scope\n", + "/proc/200/maps": "5600-5700 r-xp 00000000 103:02 77 /usr/bin/sshd\n" + strings.Replace(libcDeleted, "libc.so.6", "libcrypto.so.3", 1), + "/proc/200/cgroup": "0::/system.slice/sshd.service\n", + "/proc/201/maps": libcDeleted, + "/proc/201/cgroup": "0::/system.slice/sshd.service\n", + "/proc/300/maps": libcDeleted, + "/proc/300/cgroup": "0::/system.slice/docker-0123456789abcdef0123456789abcdef.scope\n", + "/proc/400/maps": "7f00-7f10 rw-s 00000000 00:1a 9 /dev/shm/made-up (deleted)\n", + "/proc/400/cgroup": "0::/system.slice/made-up.service\n", + "/proc/500/maps": "5600-5700 r-xp 00000000 103:02 77 /usr/bin/made-up\n", + "/proc/500/cmdline": "made-up --password=secret", + "/proc/500/environ": "TOKEN=secret", + "/proc/self/maps": libcDeleted, + }, links: map[string]string{ + "/proc/1/exe": "/usr/lib/systemd/systemd", "/proc/200/exe": "/usr/bin/sshd (deleted)", "/proc/201/exe": "/usr/bin/sshd (deleted)", + "/proc/300/exe": "/usr/bin/made-up-server", + }} + got, err := MappedDeleted(p, "/proc") + if err != nil { + t.Fatal(err) + } + var said []string + for _, m := range got { + said = append(said, m.Unit+"|"+m.Container+"|"+m.Executable) + } + if strings.Join(said, " ") != "|0123456789ab|/usr/bin/made-up-server init.scope||/usr/lib/systemd/systemd sshd.service||/usr/bin/sshd" { + t.Errorf("%v", said) + } + for _, r := range p.read { + if strings.HasSuffix(r, "/cmdline") || strings.HasSuffix(r, "/environ") { + t.Errorf("read %s: a process is named by its unit and executable, never its command line or environment", r) + } + } +} + +func TestADKMSModuleNotBuiltForAnInstalledKernelFailsTheCheck(t *testing.T) { + m := checkMachine("", "7.2.9-arch1-1") + table := map[string]Ran{ + "systemctl list-units --state=failed --plain --no-legend --no-pager": {}, + "pacman -Qq": {Stdout: "linux\nlinux-lts\n"}, + "pacman -Qlq -- linux": {Stdout: "/usr/lib/modules/7.2.9-arch1-1/vmlinuz\n"}, + "pacman -Qlq -- linux-lts": {Stdout: "/usr/lib/modules/6.18.54-2-lts/vmlinuz\n"}, + "dkms status": {Stdout: "zfs/2.4.4, 6.18.54-2-lts, x86_64: installed\nzfs/2.4.4, 7.2.8-arch1-2, x86_64: installed\n" + + "made-up-driver/1.0, 6.18.54-2-lts, x86_64: installed\nxpad/0.4: added\n"}, + } + m.Run = byLine(table, nil) + read := m.ReadFile + m.ReadFile = func(path string) ([]byte, error) { + if path == "/usr/src/made-up-driver-1.0/dkms.conf" { + return []byte("BUILD_EXCLUSIVE_KERNEL=\"^6\\.\"\n"), nil + } + return read(path) + } + u := checkUpgrades(t, m) + u.Root = &fakeRoot{mapped: []Mapped{{Unit: "sshd.service", Executable: "/usr/bin/sshd"}}} + c := u.AfterCheck(checkedRun(StepStarted, StepFinished), &Record{}) + if len(c.Unbuilt) != 1 || c.Unbuilt[0] != (DKMSBuild{Module: "zfs", Version: "2.4.4", Kernel: "7.2.9-arch1-1"}) || c.Passed { + t.Errorf("zfs not built for the new kernel fails the check (made-up-driver excludes it, xpad was never built): %+v", c) + } + if len(c.Mapped) != 1 || c.Mapped[0].Unit != "sshd.service" { + t.Errorf("mapped: %+v", c.Mapped) + } + + table["dkms status"] = Ran{Stdout: "zfs/2.4.4, 6.18.54-2-lts, x86_64: installed\nzfs/2.4.4, 7.2.9-arch1-1, x86_64: installed\n"} + if c := u.AfterCheck(checkedRun(StepStarted, StepFinished), &Record{}); !c.Passed || len(c.Mapped) != 1 { + t.Errorf("built for every kernel passes; a mapped file is said and does not fail it: %+v", c) + } + + u.Root = &fakeRoot{mapsErr: errors.New("the root helper's socket is not there")} + if c := u.AfterCheck(checkedRun(StepStarted, StepFinished), &Record{}); !strings.Contains(c.MappedError, "socket") { + t.Errorf("unread mappings are said: %+v", c) + } + table["dkms status"] = Ran{Status: 1, Stderr: "dkms: permission denied\n"} + if c := u.AfterCheck(checkedRun(StepStarted, StepFinished), &Record{}); c.Passed || c.DKMSError == "" { + t.Errorf("unread DKMS builds do not pass: %+v", c) + } +} diff --git a/modules/pacman/cmd/pacman-tools/packages.go b/modules/pacman/cmd/pacman-tools/packages.go new file mode 100644 index 00000000..4c52b9e7 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/packages.go @@ -0,0 +1,144 @@ +package main + +// What the keeper reads from the machine into the bus (novox/hq ADR 0507 §2, §4, §7): the local package +// database (`pacman -Q`, `pacman -Qeq`) with each package's repository from the sync databases (`pacman -Sl`), +// and what a full upgrade would change (`checkupdates`). Every line is read against the shape of a package name +// and a version, and a line that is not one is left out, so nothing but names, versions and repositories ever +// reaches a value on the bus (ADR 0507 §1): never a command line, an environment or a message pacman printed. +// None of these commands names a package, so none names a foreign one either. + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +var ( + // packageName is what pacman allows in a package's name (makepkg's lint: alphanumerics and @._+-). + packageName = regexp.MustCompile(`^[a-z0-9@._+][a-z0-9@._+-]*$`) + // packageVersion is an epoch, a version and a release: alphanumerics and ._+:~-. + packageVersion = regexp.MustCompile(`^[A-Za-z0-9._+~][A-Za-z0-9._+:~-]*$`) + // repoName is a repository's section name in pacman.conf. + repoName = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*$`) +) + +// nameVersion reads a ` ` line, refusing anything else. +func nameVersion(l string) (string, string, bool) { + f := strings.Fields(l) + if len(f) != 2 || !packageName.MatchString(f[0]) || !packageVersion.MatchString(f[1]) { + return "", "", false + } + return f[0], f[1], true +} + +// ReadInstalled is the node's installed packages, sorted by name, each with its version, whether it was installed +// explicitly, and its repository or RepoForeign; and how many lines of pacman's output were not a package and +// were left out. +func (m *Machine) ReadInstalled() ([]InstalledPackage, int, error) { + all, err := m.Out("pacman", "-Q") + if err != nil { + return nil, 0, err + } + explicit, err := m.query("-Qeq") + if err != nil { + return nil, 0, err + } + synced, err := m.Out("pacman", "-Sl") + if err != nil { + return nil, 0, err + } + skipped := 0 + ex := map[string]bool{} + for _, l := range lines(explicit) { + if n := strings.TrimSpace(l); packageName.MatchString(n) { + ex[n] = true + } + } + // A package in two repositories is the first's: `pacman -Sl` lists them in pacman.conf's order. + repo := map[string]string{} + for _, l := range lines(synced) { + f := strings.Fields(l) + if len(f) < 3 || !repoName.MatchString(f[0]) || !packageName.MatchString(f[1]) { + continue + } + if _, seen := repo[f[1]]; !seen { + repo[f[1]] = f[0] + } + } + pkgs := []InstalledPackage{} + for _, l := range lines(all) { + name, version, ok := nameVersion(l) + if !ok { + skipped++ + continue + } + p := InstalledPackage{Name: name, Version: version, Explicit: ex[name], Repo: repo[name]} + if p.Repo == "" { + p.Repo = RepoForeign + } + pkgs = append(pkgs, p) + } + sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Name < pkgs[j].Name }) + return pkgs, skipped, nil +} + +var updateLine = regexp.MustCompile(`^(\S+) (\S+) -> (\S+)$`) + +// UpdateList is what a full upgrade would change, sorted by name, from checkupdates: a copy of the sync +// databases refreshed apart from the machine's own, so asking never makes a partial upgrade possible. Its +// status 2 is nothing to do; any other failure is an error, never an empty list. +func (m *Machine) UpdateList() ([]VersionChange, error) { + r := m.Run(bg(), "checkupdates") + switch { + case r.Err == "ENOENT": + return nil, fmt.Errorf("checkupdates is not installed: it comes with pacman-contrib, which this module declares") + case r.Err == "" && r.Status == 2: + return []VersionChange{}, nil + case r.Err != "" || r.Status != 0: + return nil, failure("checkupdates", "checkupdates", r) + } + ups := []VersionChange{} + for _, l := range lines(r.Stdout) { + u := updateLine.FindStringSubmatch(strings.TrimSpace(l)) + if u == nil || !packageName.MatchString(u[1]) || !packageVersion.MatchString(u[2]) || !packageVersion.MatchString(u[3]) { + continue + } + ups = append(ups, VersionChange{Name: u[1], From: u[2], To: u[3]}) + } + sort.Slice(ups, func(i, j int) bool { return ups[i].Name < ups[j].Name }) + return ups, nil +} + +// DiffInstalled is what changed from one package list to the next, each list sorted by name: a package added, +// removed, or at another version. A change of reason or repository alone is no change of a version. +func DiffInstalled(before, after []InstalledPackage) Changes { + c := Changes{Added: []PackageVersion{}, Removed: []PackageVersion{}, Changed: []VersionChange{}} + was := map[string]string{} + for _, p := range before { + was[p.Name] = p.Version + } + is := map[string]bool{} + for _, p := range after { + is[p.Name] = true + v, ok := was[p.Name] + switch { + case !ok: + c.Added = append(c.Added, PackageVersion{p.Name, p.Version}) + case v != p.Version: + c.Changed = append(c.Changed, VersionChange{p.Name, v, p.Version}) + } + } + for _, p := range before { + if !is[p.Name] { + c.Removed = append(c.Removed, PackageVersion{p.Name, p.Version}) + } + } + sort.Slice(c.Added, func(i, j int) bool { return c.Added[i].Name < c.Added[j].Name }) + sort.Slice(c.Removed, func(i, j int) bool { return c.Removed[i].Name < c.Removed[j].Name }) + sort.Slice(c.Changed, func(i, j int) bool { return c.Changed[i].Name < c.Changed[j].Name }) + return c +} + +// Empty says a change changed nothing. +func (c Changes) Empty() bool { return len(c.Added)+len(c.Removed)+len(c.Changed) == 0 } diff --git a/modules/pacman/cmd/pacman-tools/pacman_test.go b/modules/pacman/cmd/pacman-tools/pacman_test.go index 4ca7f5bb..0c7f1d43 100644 --- a/modules/pacman/cmd/pacman-tools/pacman_test.go +++ b/modules/pacman/cmd/pacman-tools/pacman_test.go @@ -1,7 +1,6 @@ package main import ( - "errors" "strings" "testing" "time" @@ -78,31 +77,20 @@ func TestInfoReadsListsAsListsAndFallsBackToTheRepositories(t *testing.T) { } } -func TestInstalledSaysWhyAndWhatIsForeign(t *testing.T) { +func TestReadInstalledSaysWhyAndWhichRepositoryOrForeign(t *testing.T) { m := machine(byLine(map[string]Ran{ - "pacman -Q": {Stdout: "glibc 2.42-1\nyay 12.0-1\nzsh 5.9-1\n"}, + "pacman -Q": {Stdout: "zsh 5.9-1\nglibc 2.42-1\nyay 12.0-1\n"}, "pacman -Qeq": {Stdout: "yay\nzsh\n"}, - "pacman -Qmq": {Stdout: "yay\n"}, + "pacman -Sl": {Stdout: "core glibc 2.42-1 [installed]\nextra zsh 5.9-1 [installed]\nextra-testing zsh 5.9-2\n"}, }, nil), 1000) - r, err := m.Installed("", "", false, 10) - if err != nil { - t.Fatal(err) + pk, skipped, err := m.ReadInstalled() + if err != nil || skipped != 0 { + t.Fatal(err, skipped) } - pk := r["packages"].([]Package) - if pk[0].Reason != "dependency" || pk[1].Reason != "explicit" || !pk[1].Foreign || pk[2].Foreign { + want := []InstalledPackage{{"glibc", "2.42-1", false, "core"}, {"yay", "12.0-1", true, RepoForeign}, {"zsh", "5.9-1", true, "extra"}} + if !samePackages(pk, want) { t.Fatalf("%+v", pk) } - if tot := r["totals"].(map[string]int); tot["explicit"] != 2 || tot["dependency"] != 1 || tot["foreign"] != 1 { - t.Fatalf("%v", tot) - } - r, _ = m.Installed("", "", true, 10) - if r["count"] != 1 { - t.Fatalf("foreign only: %v", r) - } - r, _ = m.Installed("", "explicit", false, 1) - if r["count"] != 2 || r["truncated"] != true { - t.Fatalf("bounded: %v", r) - } } func TestOwnsAnswersNoOwnerAsAnAnswer(t *testing.T) { @@ -125,18 +113,18 @@ func TestOwnsAnswersNoOwnerAsAnAnswer(t *testing.T) { } } -func TestUpdatesReadsCheckupdatesAndItsNothingToDo(t *testing.T) { - m := machine(byLine(map[string]Ran{"checkupdates": {Stdout: "linux 6.1-1 -> 6.2-1\nzsh 5.9-1 -> 5.9-2\n"}}, nil), 1000) - r, err := m.Updates() - if err != nil || r["count"] != 2 || r["updates"].([]Update)[0] != (Update{"linux", "6.1-1", "6.2-1"}) { +func TestUpdateListReadsCheckupdatesAndItsNothingToDo(t *testing.T) { + m := machine(byLine(map[string]Ran{"checkupdates": {Stdout: "zsh 5.9-1 -> 5.9-2\nlinux 6.1-1 -> 6.2-1\n"}}, nil), 1000) + r, err := m.UpdateList() + if err != nil || len(r) != 2 || r[0] != (VersionChange{"linux", "6.1-1", "6.2-1"}) { t.Fatalf("%v %v", r, err) } m = machine(byLine(map[string]Ran{"checkupdates": {Status: 2}}, nil), 1000) - if r, err := m.Updates(); err != nil || r["count"] != 0 { + if r, err := m.UpdateList(); err != nil || r == nil || len(r) != 0 { t.Fatalf("%v %v", r, err) } m = machine(byLine(map[string]Ran{"checkupdates": {Status: 1, Stderr: "==> ERROR: Cannot fetch updates\n"}}, nil), 1000) - if _, err := m.Updates(); err == nil || !strings.Contains(err.Error(), "Cannot fetch updates") { + if _, err := m.UpdateList(); err == nil || !strings.Contains(err.Error(), "Cannot fetch updates") { t.Fatalf("%v", err) } } @@ -146,60 +134,17 @@ func lockless(m *Machine) *Machine { return m } -func TestAnUpgradeRunsAsAUnitOfItsOwnThroughSudoAndBringsTheNews(t *testing.T) { - fetch = func(string) ([]byte, error) { return nil, errors.New("no network") } - var calls []call - m := lockless(machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000)) - r, err := m.Upgrade("", 60) - if err != nil { - t.Fatal(err) - } - unit := r["started"].(string) - if unit != "mesh-pacman-upgrade-1791115200" { - t.Fatalf("unit: %s", unit) - } - last := calls[len(calls)-1] - want := "sudo -n systemd-run --unit=" + unit - if !strings.HasPrefix(last.String(), want) || !strings.HasSuffix(last.String(), "--quiet pacman -Syu --noconfirm") || strings.Contains(last.String(), "--wait") { - t.Fatalf("started as: %s", last) - } - news := r["news"].(map[string]any) - if news["reachable"] != false || !strings.Contains(news["error"].(string), "no network") { - t.Fatalf("no network is an answer: %v", news) - } -} - -func TestAnUpgradeIsRefusedWhileTheDatabaseIsLocked(t *testing.T) { - fetch = func(string) ([]byte, error) { return nil, errors.New("offline") } - var calls []call - m := machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000) - m.ReadFile = func(p string) ([]byte, error) { - if p == DBLock { - return []byte{}, nil - } - return nil, errNoFile - } - if _, err := m.Upgrade("", 60); err == nil || !strings.Contains(err.Error(), "another pacman holds") { - t.Fatalf("%v", err) - } - for _, c := range calls { - if c.name == "sudo" { - t.Fatal("started while locked") - } - } -} - func TestAnUpgradesUnitIsReadBack(t *testing.T) { unit := "mesh-pacman-upgrade-1791115200" m := machine(byLine(map[string]Ran{ "systemctl show " + unit + " --no-pager --property=LoadState --property=ActiveState --property=SubState --property=Result --property=ExecMainStatus": {Stdout: "LoadState=loaded\nActiveState=failed\nSubState=failed\nResult=exit-code\nExecMainStatus=1\n"}, "sudo -n journalctl --no-pager -o cat -n 60 -u " + unit: {Stdout: "error: failed to commit transaction (conflicting files)\n"}, }, nil), 1000) - r, err := m.Upgrade(unit, 60) + r, err := m.UpgradeStatus(unit, 60) if err != nil || r["finished"] != true || r["succeeded"] != false || r["exit_status"] != "1" || len(r["log"].([]string)) != 1 { t.Fatalf("%v %v", r, err) } - if _, err := m.Upgrade("sshd.service", 60); err == nil { + if _, err := m.UpgradeStatus("sshd.service", 60); err == nil { t.Fatal("a unit the tools did not start was read") } } @@ -374,57 +319,3 @@ func TestConfigIsReadAsPacmanConfResolvesIt(t *testing.T) { t.Fatalf("%+v", c) } } - -// The weekly upgrade's outcome is the module's health (issue 1574, ADR 0477 clause 5). -func upgradeHealthMachine(props, journal string) *Machine { - return machine(byLine(map[string]Ran{ - "systemctl show " + WeeklyUnit + " --no-pager --property=LoadState --property=ActiveState --property=Result --property=ExecMainStatus --property=ExecMainExitTimestamp --property=ExecMainStartTimestamp": {Stdout: props}, - "sudo -n journalctl --no-pager -o cat -n 40 -u " + WeeklyUnit: {Stdout: journal}, - "sudo -n journalctl --no-pager -o cat -n 1 -u " + WeeklyUnit: {Stdout: journal}, - }, nil), 1000) -} - -func TestAFailedWeeklyUpgradeIsUnhealthyNamingTheError(t *testing.T) { - h := upgradeHealthMachine("LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\nExecMainExitTimestamp=Sat 2026-10-24 04:03:11 CEST\n", - ":: Synchronizing package databases...\nerror: failed to commit transaction (conflicting files)\nErrors occurred, no packages were upgraded.\n").UpgradeHealth() - if h.Healthy || !strings.Contains(h.Why, "failed to commit transaction (conflicting files)") || !strings.Contains(h.Why, "exit-code") || !strings.Contains(h.Why, "Sat 2026-10-24 04:03:11 CEST") { - t.Fatalf("%+v", h) - } - h = upgradeHealthMachine("LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\nExecMainExitTimestamp=\n", "").UpgradeHealth() - if h.Healthy || !strings.Contains(h.Why, "status 1") { - t.Fatalf("a failure without a journal is still unhealthy: %+v", h) - } -} - -func TestAPassedRunningOrNeverRunWeeklyUpgradeIsHealthy(t *testing.T) { - for name, props := range map[string]string{ - "passed": "LoadState=loaded\nActiveState=inactive\nResult=success\nExecMainStatus=0\nExecMainExitTimestamp=Sat 2026-10-24 04:09:00 CEST\n", - "running": "LoadState=loaded\nActiveState=activating\nResult=success\nExecMainStatus=0\nExecMainExitTimestamp=\nExecMainStartTimestamp=Wed 2026-10-04 11:50:00 UTC\n", - "never run": "LoadState=loaded\nActiveState=inactive\nResult=success\nExecMainStatus=0\nExecMainExitTimestamp=\n", - } { - if h := upgradeHealthMachine(props, "").UpgradeHealth(); !h.Healthy { - t.Errorf("%s: %+v", name, h) - } - } - if h := machine(fake(func(call) Ran { return Ran{Status: 1, Stderr: "boom"} }, nil), 1000).UpgradeHealth(); h.Healthy { - t.Errorf("a unit that cannot be read is not healthy: %+v", h) - } -} - -// A run that goes on past RunLimit is hung and holds pacman's lock: unhealthy, naming its start (issue 1574). -func TestAHungWeeklyUpgradeIsUnhealthyNamingItsStart(t *testing.T) { - // the test machine's clock is 2026-10-04 12:00 UTC - h := upgradeHealthMachine("LoadState=loaded\nActiveState=activating\nResult=success\nExecMainStatus=0\nExecMainStartTimestamp=Wed 2026-10-04 09:00:00 UTC\n", - "downloading linux-6.99...\n").UpgradeHealth() - if h.Healthy || !strings.Contains(h.Why, "Wed 2026-10-04 09:00:00 UTC") || !strings.Contains(h.Why, "downloading linux-6.99") { - t.Fatalf("started 3 h ago: %+v", h) - } - h = upgradeHealthMachine("LoadState=loaded\nActiveState=activating\nResult=success\nExecMainStatus=0\nExecMainStartTimestamp=Wed 2026-10-04 11:50:00 UTC\n", "").UpgradeHealth() - if !h.Healthy { - t.Fatalf("started 10 min ago: %+v", h) - } - h = upgradeHealthMachine("LoadState=loaded\nActiveState=activating\nResult=success\nExecMainStatus=0\nExecMainStartTimestamp=\n", "").UpgradeHealth() - if h.Healthy { - t.Fatalf("a start that cannot be read is not healthy: %+v", h) - } -} diff --git a/modules/pacman/cmd/pacman-tools/query.go b/modules/pacman/cmd/pacman-tools/query.go index 881263ae..ae8c6626 100644 --- a/modules/pacman/cmd/pacman-tools/query.go +++ b/modules/pacman/cmd/pacman-tools/query.go @@ -8,7 +8,6 @@ import ( "fmt" "path" "regexp" - "sort" "strings" ) @@ -177,10 +176,11 @@ func (m *Machine) Info(name string) (map[string]any, error) { // Package is an installed package and why it is installed. type Package struct { - Name string `json:"name"` - Version string `json:"version"` - Reason string `json:"reason"` - Foreign bool `json:"foreign"` + Name string `json:"name"` + Version string `json:"version"` + Reason string `json:"reason"` + Repository string `json:"repository,omitempty"` + Foreign bool `json:"foreign"` } func nameVersions(out string) [][2]string { @@ -193,51 +193,6 @@ func nameVersions(out string) [][2]string { return nv } -func nameSet(out string) map[string]bool { - s := map[string]bool{} - for _, l := range lines(out) { - s[strings.TrimSpace(l)] = true - } - return s -} - -// Installed is every installed package with its version, whether it was installed explicitly or as -// a dependency, and whether it is foreign (in no repository this machine syncs). -func (m *Machine) Installed(match, reason string, foreignOnly bool, limit int) (map[string]any, error) { - all, err := m.query("-Q") - if err != nil { - return nil, err - } - explicit, err := m.query("-Qeq") - if err != nil { - return nil, err - } - foreign, err := m.query("-Qmq") - if err != nil { - return nil, err - } - ex, fo := nameSet(explicit), nameSet(foreign) - pkgs := []Package{} - counts := map[string]int{"explicit": 0, "dependency": 0, "foreign": 0} - for _, nv := range nameVersions(all) { - p := Package{Name: nv[0], Version: nv[1], Reason: "dependency", Foreign: fo[nv[0]]} - if ex[p.Name] { - p.Reason = "explicit" - } - counts[p.Reason]++ - if p.Foreign { - counts["foreign"]++ - } - if match != "" && !strings.Contains(p.Name, match) || reason != "" && p.Reason != reason || foreignOnly && !p.Foreign { - continue - } - pkgs = append(pkgs, p) - } - out := bound("packages", pkgs, limit) - out["totals"] = counts - return out, nil -} - var ownedBy = regexp.MustCompile(`^(.*) is owned by (\S+) (\S+)$`) // Owns is which package owns a path. @@ -292,51 +247,6 @@ func (m *Machine) Orphans() (map[string]any, error) { return map[string]any{"count": len(pkgs), "orphans": pkgs}, nil } -// Foreign is every installed package no repository this machine syncs carries: built from the AUR -// or by hand, which the host's `package` shape cannot install (research 027, question 1). -func (m *Machine) Foreign() (map[string]any, error) { - out, err := m.query("-Qm") - if err != nil { - return nil, err - } - pkgs := []map[string]string{} - for _, nv := range nameVersions(out) { - pkgs = append(pkgs, map[string]string{"name": nv[0], "version": nv[1]}) - } - sort.Slice(pkgs, func(i, j int) bool { return pkgs[i]["name"] < pkgs[j]["name"] }) - return map[string]any{"count": len(pkgs), "packages": pkgs}, nil -} - -// Update is one package an upgrade would change. -type Update struct { - Name string `json:"name"` - From string `json:"from"` - To string `json:"to"` -} - -var updateLine = regexp.MustCompile(`^(\S+) (\S+) -> (\S+)`) - -// Updates is what a full upgrade would change, from checkupdates: a copy of the sync databases -// refreshed apart from the machine's own, so asking never makes a partial upgrade possible. -func (m *Machine) Updates() (map[string]any, error) { - r := m.Run(bg(), "checkupdates") - switch { - case r.Err == "ENOENT": - return nil, fmt.Errorf("checkupdates is not installed: it comes with pacman-contrib, which this module declares") - case r.Err == "" && r.Status == 2: - return map[string]any{"count": 0, "updates": []Update{}}, nil - case r.Err != "" || r.Status != 0: - return nil, failure("checkupdates", "checkupdates", r) - } - ups := []Update{} - for _, l := range lines(r.Stdout) { - if u := updateLine.FindStringSubmatch(strings.TrimSpace(l)); u != nil { - ups = append(ups, Update{u[1], u[2], u[3]}) - } - } - return map[string]any{"count": len(ups), "updates": ups}, nil -} - // Config is the configuration pacman runs with, as pacman-conf resolves it. type Config struct { Options map[string][]string `json:"options"` diff --git a/modules/pacman/cmd/pacman-tools/record.go b/modules/pacman/cmd/pacman-tools/record.go new file mode 100644 index 00000000..25570861 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/record.go @@ -0,0 +1,183 @@ +package main + +// The package record taken before every upgrade (novox/hq ADR 0508 §2): a copy of the node's `installed` key, +// the running kernel, the units failed at that moment and the installed DKMS modules, written to `records` at +// `.` and read back. A run does not start, and says why, when the node's packages are marked not +// current or the record cannot be written and read back equal. Records older than `record-days` are deleted. + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// OSRelease is the running kernel's release. +const OSRelease = "/proc/sys/kernel/osrelease" + +// defaultRecordDays is record-days' default in the manifest, for a config file that holds none. +const defaultRecordDays = 180 + +// FailedUnits are the units of the service manager failed now, by name. +func (m *Machine) FailedUnits() ([]string, error) { + out, err := m.Out("systemctl", "list-units", "--state=failed", "--plain", "--no-legend", "--no-pager") + if err != nil { + return nil, err + } + units := []string{} + for _, l := range lines(out) { + if f := strings.Fields(l); len(f) > 0 { + units = append(units, f[0]) + } + } + sort.Strings(units) + return units, nil +} + +// ParseDKMSStatus reads `dkms status`: each module at its version and the kernels it is installed for. A module +// only added (not built for any kernel) is listed with none. Both forms dkms has written are read: +// `name/version, kernel, arch: state` and the older `name, version, kernel, arch: state`. +func ParseDKMSStatus(out string) []DKMSModule { + byModule := map[string]*DKMSModule{} + var order []string + for _, l := range lines(out) { + head, state, ok := strings.Cut(l, ": ") + if !ok { + continue + } + parts := strings.Split(head, ", ") + var name, version, kernel string + if n, v, slash := strings.Cut(parts[0], "/"); slash { + name, version = n, v + if len(parts) > 1 { + kernel = parts[1] + } + } else if len(parts) >= 2 { + name, version = parts[0], parts[1] + if len(parts) > 2 { + kernel = parts[2] + } + } else { + continue + } + key := name + "/" + version + m := byModule[key] + if m == nil { + m = &DKMSModule{Module: name, Version: version, Kernels: []string{}} + byModule[key] = m + order = append(order, key) + } + if kernel != "" && strings.HasPrefix(strings.TrimSpace(state), "installed") { + m.Kernels = append(m.Kernels, kernel) + } + } + sort.Strings(order) + modules := []DKMSModule{} + for _, k := range order { + sort.Strings(byModule[k].Kernels) + modules = append(modules, *byModule[k]) + } + return modules +} + +// DKMSModules are the DKMS modules installed on the machine; none when dkms is not installed. +func (m *Machine) DKMSModules() ([]DKMSModule, error) { + r := m.Run(bg(), "dkms", "status") + if r.Err == "ENOENT" { + return []DKMSModule{}, nil + } + if r.Status != 0 || r.Err != "" { + return nil, failure("dkms", "dkms", r) + } + return ParseDKMSStatus(r.Stdout), nil +} + +// TakeRecord takes the node's record for a run, writes it and reads it back. It answers the record, or why +// it could not be taken: a run that has a why does not start. +func (u *Upgrades) TakeRecord(run string) (*Record, string) { + nc, err := u.Installed.Get(NotCurrentKey(u.Node)) + if err != nil { + return nil, "the node's packages could not be read from the bus: " + err.Error() + } + if nc != nil { + var mark NotCurrent + _ = json.Unmarshal(nc.Value, &mark) + return nil, fmt.Sprintf("the node's packages on the bus are not current since %s (%d bytes, over the bus's cap on a value): a record of them would not be what is installed", + mark.Since.Format(time.RFC3339), mark.Bytes) + } + e, err := u.Installed.Get(u.Node) + if err != nil { + return nil, "the node's packages could not be read from the bus: " + err.Error() + } + if e == nil { + return nil, "the node's packages are not on the bus yet: there is nothing to record" + } + var installed InstalledValue + if err := json.Unmarshal(e.Value, &installed); err != nil { + return nil, "the node's packages on the bus are not readable: " + err.Error() + } + kernel, err := u.Machine.ReadFile(OSRelease) + if err != nil { + return nil, "the running kernel could not be read: " + err.Error() + } + failed, err := u.Machine.FailedUnits() + if err != nil { + return nil, "the failed units could not be read: " + err.Error() + } + dkms, err := u.Machine.DKMSModules() + if err != nil { + return nil, "the DKMS modules could not be read: " + err.Error() + } + rec := Record{Node: u.Node, Run: run, At: u.Machine.Now().UTC(), Installed: installed, + Kernel: strings.TrimSpace(string(kernel)), FailedUnits: failed, DKMS: dkms} + key := NodeKey(u.Node, run) + if _, err := u.Records.Put(key, rec); err != nil { + return nil, "the record could not be written: " + err.Error() + } + back, err := u.Records.Get(key) + if err != nil { + return nil, "the record could not be read back: " + err.Error() + } + if back == nil { + return nil, "the record was written and is not there when read back" + } + var read Record + want, _ := json.Marshal(rec) + if err := json.Unmarshal(back.Value, &read); err != nil { + return nil, "the record read back is not readable: " + err.Error() + } + if got, _ := json.Marshal(read); !bytes.Equal(got, want) { + return nil, "the record read back differs from the one written" + } + return &rec, "" +} + +// PruneRecords deletes the node's records older than record-days, judged by the time of their run, and +// answers how many it deleted. +func (u *Upgrades) PruneRecords() (int, error) { + days := u.Config.RecordDays + if days < 1 { + days = defaultRecordDays + } + cutoff := u.Machine.Now().AddDate(0, 0, -days) + keys, err := u.Records.Keys() + if err != nil { + return 0, fmt.Errorf("listing records: %w", err) + } + n := 0 + for _, k := range keys { + node, id, ok := strings.Cut(k, ".") + if !ok || node != u.Node { + continue + } + if t, ok := runTime(id); ok && t.Before(cutoff) { + if err := u.Records.Delete(k); err != nil { + return n, fmt.Errorf("deleting record %s: %w", k, err) + } + n++ + } + } + return n, nil +} diff --git a/modules/pacman/cmd/pacman-tools/record_test.go b/modules/pacman/cmd/pacman-tools/record_test.go new file mode 100644 index 00000000..ac1dca95 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/record_test.go @@ -0,0 +1,152 @@ +package main + +// The record before every upgrade (novox/hq ADR 0508 §2; acceptance 1841/AC3). + +import ( + "encoding/json" + "errors" + "os" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func capturedDKMSStatus(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile("testdata/dkms-status-installed-and-added/status") + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// recordMachine is a machine running one kernel, with one unit failed and the captured DKMS modules. +func recordMachine(t *testing.T) *Machine { + m := machine(byLine(map[string]Ran{ + "systemctl list-units --state=failed --plain --no-legend --no-pager": {Stdout: "backup.service loaded failed failed The backup\n"}, + "dkms status": {Stdout: capturedDKMSStatus(t)}, + }, nil), 1000) + m.ReadFile = func(path string) ([]byte, error) { + if path == OSRelease { + return []byte("7.2.8-arch1-2\n"), nil + } + return nil, errNoFile + } + return m +} + +var nodeAPackages = InstalledValue{At: time.Date(2026, 10, 3, 9, 0, 0, 0, time.UTC), Packages: []InstalledPackage{ + {Name: "linux", Version: "7.2.8.arch1-2", Explicit: true, Repo: "core"}, + {Name: "zfs-dkms", Version: "2.4.4-1", Explicit: true, Repo: RepoForeign}, +}} + +func recordUpgrades(t *testing.T) (*Upgrades, *memBucket, *memBucket) { + installed, records := newBucket(), newBucket() + if _, err := installed.Put("node-a", nodeAPackages); err != nil { + t.Fatal(err) + } + return &Upgrades{Node: "node-a", Machine: recordMachine(t), Installed: installed, Records: records}, installed, records +} + +func TestDKMSStatusInBothFormsIsEachModuleWithTheKernelsItIsBuiltFor(t *testing.T) { + mods := ParseDKMSStatus(capturedDKMSStatus(t) + "zfs, 2.4.4, 7.2.8-arch1-2, x86_64: installed\nzfs, 2.4.4, 6.18.54-2-lts, x86_64: installed\n") + var got []string + for _, m := range mods { + got = append(got, m.Module+"/"+m.Version+"="+strings.Join(m.Kernels, "+")) + } + want := "openrazer-driver/3.12.4=7.2.8-arch1-2 vboxhost/7.2.20_OSE=7.2.8-arch1-2 xone/0.5.8=7.2.8-arch1-2 xpad/0.4= zfs/2.4.4=6.18.54-2-lts+7.2.8-arch1-2" + if strings.Join(got, " ") != want { + t.Errorf("%v", got) + } +} + +func TestARecordIsTheNodesPackagesKernelFailedUnitsAndDKMSWrittenAndReadBack(t *testing.T) { + u, _, records := recordUpgrades(t) + rec, why := u.TakeRecord("20261004T120000Z") + if why != "" || rec == nil { + t.Fatalf("refused: %s", why) + } + var kept Record + if !records.value("node-a.20261004T120000Z", &kept) { + t.Fatal("no record at .") + } + a, _ := json.Marshal(kept.Installed) + b, _ := json.Marshal(nodeAPackages) + if string(a) != string(b) { + t.Errorf("the record copies installed.: %s", a) + } + if kept.Kernel != "7.2.8-arch1-2" || strings.Join(kept.FailedUnits, " ") != "backup.service" || len(kept.DKMS) != 4 || + kept.Node != "node-a" || kept.Run != "20261004T120000Z" { + t.Errorf("record: %+v", kept) + } + if w := strings.Join(records.writes, " "); w != "put node-a.20261004T120000Z" { + t.Errorf("writes: %s", w) + } +} + +func TestNoRecordAndNoRunWhenThePackagesAreNotCurrentOrTheRecordIsNotReadBack(t *testing.T) { + u, installed, records := recordUpgrades(t) + if _, err := installed.Put(NotCurrentKey("node-a"), NotCurrent{Bytes: 300000, Since: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)}); err != nil { + t.Fatal(err) + } + if rec, why := u.TakeRecord("r1"); rec != nil || !strings.Contains(why, "not current") || !strings.Contains(why, "300000") { + t.Errorf("not current: %v %q", rec, why) + } + if len(records.writes) != 0 { + t.Errorf("a record of packages not current was written: %v", records.writes) + } + + u, installed, _ = recordUpgrades(t) + _ = installed.Delete("node-a") + if _, why := u.TakeRecord("r1"); !strings.Contains(why, "not on the bus") { + t.Errorf("no installed key: %q", why) + } + + u, _, records = recordUpgrades(t) + records.fail["put node-a.r1"] = errors.New("value over the bucket's limit") + if _, why := u.TakeRecord("r1"); !strings.Contains(why, "could not be written") || !strings.Contains(why, "over the bucket's limit") { + t.Errorf("not written: %q", why) + } + + u, _, _ = recordUpgrades(t) + u.Records = &mangling{newBucket()} + if _, why := u.TakeRecord("r1"); !strings.Contains(why, "differs") { + t.Errorf("read back unequal: %q", why) + } +} + +// mangling is a state that reads back other than what was written. +type mangling struct{ *memBucket } + +func (m *mangling) Get(key string) (*stdio.StateEntry, error) { + e, err := m.memBucket.Get(key) + if e != nil { + e.Value = []byte(strings.Replace(string(e.Value), "7.2.8-arch1-2", "7.2.7-arch1-1", 1)) + } + return e, err +} + +func TestRecordsOlderThanRecordDaysAreDeletedAndYoungerOnesKept(t *testing.T) { + u, _, records := recordUpgrades(t) + u.Config.RecordDays = 180 + now := u.Machine.Now() // 2026-10-04 + for _, k := range []string{ + NodeKey("node-a", RunID(now.AddDate(0, 0, -181))), + NodeKey("node-a", RunID(now.AddDate(0, 0, -179))), + NodeKey("node-b", RunID(now.AddDate(0, 0, -400))), + } { + if _, err := records.Put(k, Record{}); err != nil { + t.Fatal(err) + } + } + n, err := u.PruneRecords() + if err != nil || n != 1 { + t.Fatalf("%d deleted, %v", n, err) + } + keys, _ := records.Keys() + if strings.Join(keys, " ") != NodeKey("node-a", RunID(now.AddDate(0, 0, -179)))+" "+NodeKey("node-b", RunID(now.AddDate(0, 0, -400))) { + t.Errorf("kept: %v (the younger record, and another node's, which its own keeper prunes)", keys) + } +} diff --git a/modules/pacman/cmd/pacman-tools/rollback.go b/modules/pacman/cmd/pacman-tools/rollback.go new file mode 100644 index 00000000..f7d315b9 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/rollback.go @@ -0,0 +1,300 @@ +package main + +// A rollback (novox/hq ADR 0508 §7; acceptance 1841/AC8): a node is returned to the record a run took, and says +// first what that would change. The plan compares the record's packages with the node's `installed` now: each +// package whose version differs, or that is gone since, is brought back to the record's version, from the node's +// package cache when it holds that file, else from the Arch Linux Archive's copy of that exact version, else from +// the archive's repository as it stood on the record's date. The packages installed since are listed, and are +// removed only when asked. A foreign package, in the record or now, is never in the plan (ADR 0507 §7). +// +// The archive keeps every version its repositories published, so a version is taken from it by its file's name, +// which needs the package's architecture: the architecture of any of the package's files in the cache, else of +// its installed version (the local database). A package whose architecture is not known on this machine is +// taken from the dated repository, where pacman finds it by name. Nothing is fetched to make the plan; a file +// the archive does not hold fails the transaction, which the run then says. +// +// With apply, pacman_rollback writes an ask; the node's keeper takes it (TakeAsk), makes the plan again on the +// node itself (its cache is the one that counts), and runs it as a run of kind rollback: one transaction started +// by the root helper with the plan in the request (RootActs.Rollback), `rollback.started` and +// `rollback.finished`, then the after-upgrade check against the record (Follow, checkAfter). + +import ( + "encoding/json" + "errors" + "fmt" + "path" + "regexp" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// ArchiveURL is the Arch Linux Archive. +const ArchiveURL = "https://archive.archlinux.org" + +// SourceRemoved marks a plan item that removes a package installed since the record (to "" and no location): +// only when the rollback was asked to remove what the upgrade added. +const SourceRemoved = "removed" + +// RollbackPlan is what a rollback to a run's record would change. +type RollbackPlan struct { + Node string `json:"node"` + Of string `json:"of"` + // RecordAt is when the record was taken; the dated repository is that day's. + RecordAt time.Time `json:"record_at"` + // Items brings each package back, or removes one installed since when RemoveAdded. + Items []RollbackItem `json:"items"` + // Added is the packages installed since the record, not foreign. + Added []PackageVersion `json:"added"` + RemoveAdded bool `json:"remove_added"` + // Foreign is how many foreign packages the plan leaves untouched. + Foreign int `json:"foreign_untouched"` + // CacheRead says whether the node's package cache was read: only on the node itself. + CacheRead bool `json:"cache_read"` +} + +// cacheFile is `--.pkg.tar.`; a name may hold dashes, a version and an arch none. +var cacheFile = regexp.MustCompile(`^(.+)-([^-]+-[^-]+)-([^-]+)\.pkg\.tar\.(zst|xz)$`) + +// packageCache is the node's cache: by package name, by version, the file's path; and each name's arch. +type packageCache struct { + files map[string]map[string]string + arch map[string]string +} + +// readCache lists the cache's package files. +func (m *Machine) readCache() (packageCache, error) { + c := packageCache{files: map[string]map[string]string{}, arch: map[string]string{}} + out, err := m.Out("find", CacheDir, "-mindepth", "1", "-maxdepth", "1", "-name", "*.pkg.tar.*", "-printf", "%f\n") + if err != nil { + return c, err + } + for _, l := range lines(out) { + f := cacheFile.FindStringSubmatch(strings.TrimSpace(l)) + if f == nil || !packageName.MatchString(f[1]) || !packageVersion.MatchString(f[2]) { + continue + } + if c.files[f[1]] == nil { + c.files[f[1]] = map[string]string{} + } + if _, ok := c.files[f[1]][f[2]]; !ok || f[4] == "zst" { + c.files[f[1]][f[2]] = path.Join(CacheDir, f[0]) + } + c.arch[f[1]] = f[3] + } + return c, nil +} + +// installedArch is an installed package's architecture from the local database, or "". +func (m *Machine) installedArch(name, version string) string { + desc, err := m.ReadFile(path.Join(PacmanDBDir, "local", name+"-"+version, "desc")) + if err != nil { + return "" + } + ls := strings.Split(string(desc), "\n") + for i, l := range ls { + if strings.TrimSpace(l) == "%ARCH%" && i+1 < len(ls) { + if a := strings.TrimSpace(ls[i+1]); repoName.MatchString(a) { + return a + } + } + } + return "" +} + +// source is where a package's record version comes from. +func source(name, version, repo string, arch string, cache *packageCache, recordAt time.Time) (string, string) { + if cache != nil { + if p, ok := cache.files[name][version]; ok { + return SourceCache, p + } + } + if arch != "" { + file := name + "-" + version + "-" + arch + ".pkg.tar.zst" + return SourceArchive, ArchiveURL + "/packages/" + name[:1] + "/" + name + "/" + file + } + day := recordAt.UTC().Format("2006/01/02") + return SourceRepository, ArchiveURL + "/repos/" + day + "/" + repo + "/os/$arch" +} + +// keyGetter reads one key: a Bucket the keeper writes, or a StateReader a verb reads. +type keyGetter interface { + Get(key string) (*stdio.StateEntry, error) +} + +// PlanRollback is the plan for returning node to the record of run, from the states: `records..` and +// `installed.`, which must be current. local says this is the node itself, whose cache and local database +// are read. +func PlanRollback(m *Machine, installed, records keyGetter, node, run string, removeAdded, local bool) (RollbackPlan, error) { + p := RollbackPlan{Node: node, Of: run, Items: []RollbackItem{}, Added: []PackageVersion{}, RemoveAdded: removeAdded} + if e, err := installed.Get(NotCurrentKey(node)); err != nil { + return p, err + } else if e != nil { + return p, fmt.Errorf("the packages of %s on the bus are not current: a plan from them would not be what is installed", node) + } + var rec Record + if e, err := records.Get(NodeKey(node, run)); err != nil { + return p, err + } else if e == nil { + return p, fmt.Errorf("%s has no record of run %s: it was never taken, or is older than record-days", node, run) + } else if err := json.Unmarshal(e.Value, &rec); err != nil { + return p, fmt.Errorf("the record of run %s is not readable: %w", run, err) + } + var now InstalledValue + if e, err := installed.Get(node); err != nil { + return p, err + } else if e == nil { + return p, fmt.Errorf("no packages of %s are on the bus", node) + } else if err := json.Unmarshal(e.Value, &now); err != nil { + return p, fmt.Errorf("installed.%s is not readable: %w", node, err) + } + p.RecordAt = rec.At + var cache *packageCache + if local { + c, err := m.readCache() + if err != nil { + return p, fmt.Errorf("reading the package cache: %w", err) + } + cache, p.CacheRead = &c, true + } + foreign := map[string]bool{} + nowBy := map[string]InstalledPackage{} + for _, ip := range now.Packages { + nowBy[ip.Name] = ip + if ip.Repo == RepoForeign { + foreign[ip.Name] = true + } + } + recBy := map[string]InstalledPackage{} + for _, ip := range rec.Installed.Packages { + recBy[ip.Name] = ip + if ip.Repo == RepoForeign { + foreign[ip.Name] = true + } + } + p.Foreign = len(foreign) + for _, want := range rec.Installed.Packages { + if foreign[want.Name] { + continue + } + have, installedNow := nowBy[want.Name] + if installedNow && have.Version == want.Version { + continue + } + arch := "" + if cache != nil { + arch = cache.arch[want.Name] + } + if arch == "" && local && installedNow { + arch = m.installedArch(have.Name, have.Version) + } + src, loc := source(want.Name, want.Version, want.Repo, arch, cache, rec.At) + p.Items = append(p.Items, RollbackItem{Name: want.Name, From: have.Version, To: want.Version, Source: src, Location: loc}) + } + for _, have := range now.Packages { + if _, inRecord := recBy[have.Name]; inRecord || foreign[have.Name] { + continue + } + p.Added = append(p.Added, PackageVersion{have.Name, have.Version}) + if removeAdded { + p.Items = append(p.Items, RollbackItem{Name: have.Name, From: have.Version, Source: SourceRemoved}) + } + } + sort.Slice(p.Items, func(i, j int) bool { return p.Items[i].Name < p.Items[j].Name }) + return p, nil +} + +// AskRollback writes an ask for a rollback of node to the record of run, for that node's keeper to take. +func AskRollback(asks Bucket, node, run string, removeAdded bool, now time.Time) (Ask, error) { + a := Ask{ID: AskID(now), Node: node, Act: ActRollback, At: now.UTC(), Of: run, RemoveAdded: removeAdded} + if _, err := asks.Create(NodeKey(node, a.ID), a); err != nil { + return Ask{}, fmt.Errorf("writing the ask: %w", err) + } + return a, nil +} + +// StartRollback runs a rollback's steps up to its transaction's start: the plan made again on the node, and the +// transaction started by the root helper with it. The end and the check follow as for an upgrade (Follow). +func (u *Upgrades) StartRollback(of string, removeAdded bool, ask string) (Run, error) { + now := u.Machine.Now().UTC() + r := &openRun{Run: Run{Node: u.Node, Run: RunID(now), Kind: RunRollback, From: FromAsk, Ask: ask, Of: of}} + runs, err := u.ownRuns() + if err != nil { + return r.Run, err + } + for _, o := range runs { + if o.Run.Run == r.Run.Run { + return r.Run, fmt.Errorf("run %s of %s is there already: one run a second", o.Run.Run, u.Node) + } + if holdsTheDatabase(o.Run) { + return r.Run, u.refuse(r, fmt.Sprintf("run %s is still going (its last step is %s): one run at a time", o.Run.Run, lastStep(o.Run))) + } + } + if u.Machine.locked() { + return r.Run, u.refuse(r, "another pacman holds "+DBLock+": a transaction is running, or one was killed and left its lock") + } + plan, err := PlanRollback(u.Machine, u.Installed, u.Records, u.Node, of, removeAdded, true) + if err != nil { + return r.Run, u.refuse(r, err.Error()) + } + if len(plan.Items) == 0 { + return r.Run, u.refuse(r, "the node's packages are those of the record already: there is nothing to bring back") + } + unit := TransactionUnit(RunRollback, r.Run.Run) + if err := u.Root.Rollback(r.Run.Run, plan.Items); err != nil { + return r.Run, u.end(r, Step{Step: StepFailed, At: u.Machine.Now().UTC(), Error: "the transaction could not be started: " + err.Error()}) + } + if err := u.step(r, Step{Step: StepStarted, At: now, Unit: unit, Plan: plan.Items}); err != nil { + return r.Run, err + } + return r.Run, u.emit(EventRollbackStarted, RollbackStarted{Node: u.Node, Run: r.Run.Run, Of: of, Unit: unit}) +} + +// takeRollback carries out an ask for a rollback; the ask is deleted once a run names it. +func (u *Upgrades) takeRollback(key string, a Ask) error { + if a.Of == "" { + return errors.Join(fmt.Errorf("ask %s names no run to return to", key), u.Asks.Delete(key)) + } + r, err := u.StartRollback(a.Of, a.RemoveAdded, a.ID) + if len(r.Steps) == 0 { + return err + } + if derr := u.Asks.Delete(key); derr != nil { + err = errors.Join(err, fmt.Errorf("deleting the ask %s: %w", key, derr)) + } + return err +} + +// Rollback is pacman_rollback: the plan, read from the bus (and on the node itself its cache), and with apply +// the ask that has the node's keeper carry it out. +func (m *Machine) Rollback(node, run string, removeAdded, apply bool) (map[string]any, error) { + if node == "" { + node = m.Node + } + if node == "" { + return nil, errors.New("node is required: this bundle does not know its own") + } + if err := m.states(); err != nil { + return nil, err + } + plan, err := PlanRollback(m, m.States(StateInstalled), m.States(StateRecords), node, run, removeAdded, node == m.Node) + if err != nil { + return nil, err + } + out := map[string]any{"plan": plan} + if !apply { + return out, nil + } + if len(plan.Items) == 0 { + return nil, fmt.Errorf("the packages of %s are those of the record already: there is nothing to bring back", node) + } + a, err := AskRollback(upgradeState(StateAsks), node, run, removeAdded, m.Now()) + if err != nil { + return nil, err + } + out["asked"] = a + out["follow"] = "pacman_upgrades with node " + node + " lists the rollback run that names this ask; its keeper makes the plan again from its own cache" + return out, nil +} diff --git a/modules/pacman/cmd/pacman-tools/rollback_test.go b/modules/pacman/cmd/pacman-tools/rollback_test.go new file mode 100644 index 00000000..ff7ac532 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/rollback_test.go @@ -0,0 +1,195 @@ +package main + +// A rollback to a record (novox/hq ADR 0508 §7; acceptance 1841/AC8): the plan says each package now -> record with +// its source, the packages added and no foreign package; apply is an ask the node's keeper runs as one +// transaction, emitting rollback.started and rollback.finished and ending with the after-upgrade check. + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var recordAt = time.Date(2026, 10, 3, 4, 0, 12, 0, time.UTC) + +// rollbackRecord is node-a's record of run 20261003T040012Z; rollbackNow its packages since. +var ( + rollbackRecord = Record{Node: "node-a", Run: "20261003T040012Z", At: recordAt, Installed: InstalledValue{At: recordAt, Packages: []InstalledPackage{ + {"bash", "5.3.15-1", true, "core"}, {"glibc", "2.42-1", false, "core"}, {"linux", "7.2.8.arch1-2", true, "core"}, + {"made-up-old-library", "1.0-1", false, "extra"}, {"made-up-foreign", "1-1", true, RepoForeign}, + }}} + rollbackNow = InstalledValue{At: recordAt.Add(time.Hour), Packages: []InstalledPackage{ + {"bash", "5.3.20-1", true, "core"}, {"glibc", "2.42-1", false, "core"}, {"linux", "7.3.1.arch1-1", true, "core"}, + {"made-up-foreign", "2-1", true, RepoForeign}, {"made-up-library", "1.0-1", false, "extra"}, + }} +) + +// rollbackCache is the node's package cache: bash's recorded version, and linux at its new version only. +const rollbackCache = "bash-5.3.15-1-x86_64.pkg.tar.zst\nbash-5.3.20-1-x86_64.pkg.tar.zst\nlinux-7.3.1.arch1-1-x86_64.pkg.tar.zst\n" + + "not-a-package-file\n" + +func rollbackStates(t *testing.T) (*fakeStates, *Machine, *[]call) { + t.Helper() + states := &fakeStates{} + states.store(StateRecords).Put(NodeKey("node-a", rollbackRecord.Run), rollbackRecord) + states.store(StateInstalled).Put("node-a", rollbackNow) + var calls []call + m := machine(byLine(map[string]Ran{ + "find " + CacheDir + " -mindepth 1 -maxdepth 1 -name *.pkg.tar.* -printf %f\n": {Stdout: rollbackCache}, + }, &calls), 1000) + m.Node, m.States = "node-a", readerOf(states) + return states, m, &calls +} + +func TestThePlanSaysEachPackageNowToTheRecordWithItsSourceTheAddedAndNoForeign(t *testing.T) { + _, m, calls := rollbackStates(t) + p, err := PlanRollback(m, m.States(StateInstalled), m.States(StateRecords), "node-a", rollbackRecord.Run, false, true) + if err != nil { + t.Fatal(err) + } + want := []RollbackItem{ + {"bash", "5.3.20-1", "5.3.15-1", SourceCache, CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"}, + {"linux", "7.3.1.arch1-1", "7.2.8.arch1-2", SourceArchive, "https://archive.archlinux.org/packages/l/linux/linux-7.2.8.arch1-2-x86_64.pkg.tar.zst"}, + {"made-up-old-library", "", "1.0-1", SourceRepository, "https://archive.archlinux.org/repos/2026/10/03/extra/os/$arch"}, + } + if len(p.Items) != len(want) { + t.Fatalf("items: %+v", p.Items) + } + for i := range want { + if p.Items[i] != want[i] { + t.Errorf("item %d: %+v, want %+v", i, p.Items[i], want[i]) + } + } + if len(p.Added) != 1 || p.Added[0] != (PackageVersion{"made-up-library", "1.0-1"}) || p.Foreign != 1 || !p.CacheRead { + t.Errorf("added %+v, foreign %d, cache %v", p.Added, p.Foreign, p.CacheRead) + } + for _, c := range *calls { + if strings.Contains(c.String(), "made-up-foreign") { + t.Errorf("a command named the foreign package: %s", c) + } + } + // asked to remove what was added: the added package is removed too. + p, _ = PlanRollback(m, m.States(StateInstalled), m.States(StateRecords), "node-a", rollbackRecord.Run, true, true) + if len(p.Items) != 4 || p.Items[2] != (RollbackItem{Name: "made-up-library", From: "1.0-1", Source: SourceRemoved}) { + t.Errorf("with remove_added: %+v", p.Items) + } +} + +func TestAnotherNodesPlanReadsNoCacheAndTakesUnknownArchitecturesFromTheDatedRepository(t *testing.T) { + _, m, calls := rollbackStates(t) + m.Node = "node-b" + r, err := m.Rollback("node-a", rollbackRecord.Run, false, false) + if err != nil { + t.Fatal(err) + } + p := r["plan"].(RollbackPlan) + if p.CacheRead || len(*calls) != 0 { + t.Errorf("another node's cache was read: %v", *calls) + } + for _, i := range p.Items { + if i.Source != SourceRepository || !strings.HasPrefix(i.Location, "https://archive.archlinux.org/repos/2026/10/03/") { + t.Errorf("%+v", i) + } + } +} + +func TestNoPlanFromPackagesNotCurrentOrWithoutARecord(t *testing.T) { + states, m, _ := rollbackStates(t) + if _, err := m.Rollback("node-a", "20250101T000000Z", false, false); err == nil || !strings.Contains(err.Error(), "no record") { + t.Errorf("no record: %v", err) + } + states.store(StateInstalled).Put(NotCurrentKey("node-a"), NotCurrent{Bytes: 300000, Since: recordAt}) + if _, err := m.Rollback("node-a", rollbackRecord.Run, false, false); err == nil || !strings.Contains(err.Error(), "not current") { + t.Errorf("not current: %v", err) + } +} + +func TestApplyWritesAnAskForTheNodesKeeper(t *testing.T) { + _, m, _ := rollbackStates(t) + asks := newBucket() + saved := upgradeState + upgradeState = func(name string) Bucket { return asks } + t.Cleanup(func() { upgradeState = saved }) + r, err := m.Rollback("", rollbackRecord.Run, true, true) + if err != nil { + t.Fatal(err) + } + a := r["asked"].(Ask) + var got Ask + if !asks.value(NodeKey("node-a", a.ID), &got) || got.Act != ActRollback || got.Of != rollbackRecord.Run || !got.RemoveAdded { + t.Errorf("the ask: %+v", got) + } +} + +func TestAnAskedRollbackIsOneTransactionWithItsEventsAndTheCheck(t *testing.T) { + k := newKeeperBed(t) + k.records.Put(NodeKey("node-a", rollbackRecord.Run), rollbackRecord) + k.installed.Put("node-a", rollbackNow) + inner := k.u.Machine.Run + k.u.Machine.Run = func(ctx context.Context, name string, args ...string) Ran { + if name == "find" { + return Ran{Stdout: rollbackCache} + } + return inner(ctx, name, args...) + } + a := Ask{ID: "20261004T120000Z-abc123", Node: "node-a", Act: ActRollback, At: k.now, Of: rollbackRecord.Run} + if err := k.takeAsk(t, a); err != nil { + t.Fatal(err) + } + var r Run + keys, _ := k.runs.Keys() + for _, key := range keys { + var x Run + k.runs.value(key, &x) + if x.Kind == RunRollback { + r = x + } + } + if r.Of != rollbackRecord.Run || r.Ask != a.ID || steps(r) != "started" || len(r.Steps[0].Plan) != 3 { + t.Fatalf("the rollback run: %+v", r) + } + if strings.Join(k.root.started, ";") != "rollback "+r.Run+" plan=bash:5.3.15-1:cache,linux:7.2.8.arch1-2:archive,made-up-old-library:1.0-1:repository" { + t.Errorf("the root helper was asked: %v", k.root.started) + } + if _, there := k.asks.values[NodeKey("node-a", a.ID)]; there { + t.Error("the ask is deleted once the run names it") + } + k.unitState, k.now = "LoadState=not-found\n", k.now.Add(time.Minute) + if err := k.u.Follow(); err != nil { + t.Fatal(err) + } + k.runs.value(NodeKey("node-a", r.Run), &r) + if steps(r) != "started finished checked-after" || r.Steps[2].Check == nil { + t.Errorf("steps: %s", steps(r)) + } + if got := strings.Join(k.events.names(), " "); got != "rollback.started rollback.finished upgrade.checked" { + t.Errorf("events: %s", got) + } + // the same record again: nothing to bring back once the packages are the record's. + k.installed.Put("node-a", rollbackRecord.Installed) + k.now = k.now.Add(time.Minute) + if err := k.takeAsk(t, Ask{ID: "20261004T120200Z-def456", Node: "node-a", Act: ActRollback, At: k.now, Of: rollbackRecord.Run}); err != nil { + t.Fatal(err) + } + var again Run + k.runs.value(NodeKey("node-a", "20261004T120200Z"), &again) + if steps(again) != "refused" || !strings.Contains(again.Steps[0].Why, "nothing to bring back") { + t.Errorf("a rollback with nothing to do: %+v", again) + } +} + +// takeAsk writes an ask and hands its change to the keeper, as the watch of `asks` would. +func (k *keeperBed) takeAsk(t *testing.T, a Ask) error { + t.Helper() + key := NodeKey(a.Node, a.ID) + rev, err := k.asks.Create(key, a) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(a) + return k.u.TakeAsk(stdio.StateChange{State: StateAsks, Key: key, Op: "put", Value: raw, Revision: rev}) +} diff --git a/modules/pacman/cmd/pacman-tools/root.go b/modules/pacman/cmd/pacman-tools/root.go new file mode 100644 index 00000000..df21a062 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/root.go @@ -0,0 +1,506 @@ +package main + +// The root helper (novox/hq ADR 0510): the module's acts that need root go through a second process of the same +// build, `pacman-root`, running as root with no bus account, never through a sudo rule. It serves a closed list +// of verbs on a unix socket only the keeper's account opens, the peer's account checked on every connection: +// +// - transaction: start a run's full upgrade as a unit of the service manager, named by the run, leaving out +// the packages the run holds back; +// - rollback: start a rollback's transaction as such a unit, carrying out the plan sent with the verb; +// - maps: the processes still mapping a file an upgrade replaced, by unit or container and executable; +// - journal: the last lines a transaction unit of the module logged. +// +// Every other verb is refused, and every argument that is not a unit name of the module's own transactions, a +// package name, a version, a run's id, or a plan item's location in the package cache or the Arch Linux +// Archive. The helper's program is root's (ADR 0510 §3): the node-engine places a process's program in a +// directory its own account owns, and that account is root. +// +// A rollback's unit runs this same program (`pacman-tools root-rollback `) on a plan file the helper +// wrote into a directory only root reads: it finds each package of the dated repository by its file's name, +// installs the plan's packages in one pacman transaction, then removes the ones the rollback was asked to +// remove. Nothing is changed when a package cannot be found. + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "os/user" + "path" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +// The helper's verbs. +const ( + VerbTransaction = "transaction" + VerbRollback = "rollback" + VerbMaps = "maps" + VerbJournal = "journal" +) + +// The helper's environment, and the keeper's word for where the helper listens. +const ( + EnvRootSocket = "PACMAN_ROOT_SOCKET" // the helper's socket, for the helper and the keeper + EnvRootPeer = "PACMAN_ROOT_PEER" // the only account the helper answers: the keeper's + // RootSocketPath is the socket as the manifest names it: in a directory of the module's state the helper + // makes, root's. + RootSocketPath = "${dir:state}/root/helper.sock" +) + +// rootTimeout bounds one exchange with the helper: every verb answers at once (a transaction is started, not +// waited for). +const rootTimeout = 30 * time.Second + +// mostJournalLines is the most lines the journal verb reads. +const mostJournalLines = 400 + +// RootRequest is one verb sent to the helper, one per connection. +type RootRequest struct { + Verb string `json:"verb"` + Kind string `json:"kind,omitempty"` + Run string `json:"run,omitempty"` + Ignore []string `json:"ignore,omitempty"` + Plan []RollbackItem `json:"plan,omitempty"` + Unit string `json:"unit,omitempty"` + Lines int `json:"lines,omitempty"` +} + +// RootAnswer is the helper's answer: an error, or what the verb read. +type RootAnswer struct { + Error string `json:"error,omitempty"` + Lines []string `json:"lines,omitempty"` + Mapped []Mapped `json:"mapped,omitempty"` +} + +// RootHelper is the helper: what it runs, the process table it reads, where it keeps plans, its own program, +// and the one account it answers. +type RootHelper struct { + Run Runner + Proc ProcFS + ProcRoot string + PlanDir string + Self string + Peer int + // PeerOf is the account at the other end of a connection; nil reads it from the socket (SO_PEERCRED). + PeerOf func(net.Conn) (int, error) + Logf func(format string, a ...any) +} + +var ( + // runIDShape is a run's id (RunID): its start in UTC to the second. + runIDShape = regexp.MustCompile(`^[0-9]{8}T[0-9]{6}Z$`) + // ownUnit is a transaction unit of the module's own runs (TransactionUnit). + ownUnit = regexp.MustCompile(`^` + unitPrefix + `(` + RunUpgrade + `|` + RunRollback + `)-[0-9]{8}t[0-9]{6}z$`) + // archArg is a package's architecture in a file's name. + archArg = regexp.MustCompile(`^[a-z0-9_]+$`) + // datedRepository is the archive's repository as it stood on one day, its architecture left to pacman. + datedRepository = regexp.MustCompile(`^` + regexp.QuoteMeta(ArchiveURL) + `/repos/[0-9]{4}/[0-9]{2}/[0-9]{2}/[A-Za-z0-9_-]+/os/\$arch$`) +) + +func refused(format string, a ...any) RootAnswer { + return RootAnswer{Error: "refused: " + fmt.Sprintf(format, a...)} +} + +func checkRun(run string) error { + if !runIDShape.MatchString(run) { + return fmt.Errorf("%q is not a run's id", run) + } + return nil +} + +// checkItem refuses a plan item that is not a package brought back from the cache or the archive, or removed. +func checkItem(i RollbackItem) error { + if !packageName.MatchString(i.Name) { + return fmt.Errorf("%q is not a package's name", i.Name) + } + if i.Source == SourceRemoved { + if i.To != "" || i.Location != "" { + return fmt.Errorf("%s is removed, and names a version or a place to take it from", i.Name) + } + return nil + } + if !packageVersion.MatchString(i.To) { + return fmt.Errorf("%q is not a version of %s", i.To, i.Name) + } + switch i.Source { + case SourceCache: + f := cacheFile.FindStringSubmatch(path.Base(i.Location)) + if path.Clean(i.Location) != i.Location || path.Dir(i.Location) != CacheDir || f == nil || f[1] != i.Name || f[2] != i.To { + return fmt.Errorf("%q is not %s %s's file in the package cache", i.Location, i.Name, i.To) + } + case SourceArchive: + prefix := ArchiveURL + "/packages/" + i.Name[:1] + "/" + i.Name + "/" + i.Name + "-" + i.To + "-" + arch, ok := strings.CutPrefix(i.Location, prefix) + if !ok || !strings.HasSuffix(arch, ".pkg.tar.zst") || !archArg.MatchString(strings.TrimSuffix(arch, ".pkg.tar.zst")) { + return fmt.Errorf("%q is not %s %s's file in the archive", i.Location, i.Name, i.To) + } + case SourceRepository: + if !datedRepository.MatchString(i.Location) { + return fmt.Errorf("%q is not a dated repository of the archive", i.Location) + } + default: + return fmt.Errorf("%q is not a source a package is brought back from", i.Source) + } + return nil +} + +// Handle carries out one verb, or refuses it naming why; nothing runs for a refused verb. +func (h *RootHelper) Handle(req RootRequest) RootAnswer { + switch req.Verb { + case VerbTransaction: + if req.Kind != RunUpgrade { + return refused("a transaction is a full upgrade, not %q", req.Kind) + } + if err := checkRun(req.Run); err != nil { + return refused("%v", err) + } + for _, n := range req.Ignore { + if !packageName.MatchString(n) { + return refused("%q is not a package's name to leave out", n) + } + } + args := append([]string{"pacman"}, UpgradeArgs...) + if len(req.Ignore) > 0 { + args = append(args, "--ignore", strings.Join(req.Ignore, ",")) + } + return h.startUnit(TransactionUnit(RunUpgrade, req.Run), "pacman full upgrade of run "+req.Run, args) + case VerbRollback: + if err := checkRun(req.Run); err != nil { + return refused("%v", err) + } + if len(req.Plan) == 0 { + return refused("a rollback with no plan") + } + for _, i := range req.Plan { + if err := checkItem(i); err != nil { + return refused("%v", err) + } + } + raw, err := json.Marshal(req.Plan) + if err != nil { + return RootAnswer{Error: err.Error()} + } + if err := os.MkdirAll(h.PlanDir, 0o700); err != nil { + return RootAnswer{Error: "the plan's directory: " + err.Error()} + } + file := filepath.Join(h.PlanDir, req.Run+".json") + if err := os.WriteFile(file, raw, 0o600); err != nil { + return RootAnswer{Error: "writing the plan: " + err.Error()} + } + return h.startUnit(TransactionUnit(RunRollback, req.Run), "pacman rollback of run "+req.Run, []string{h.Self, "root-rollback", file}) + case VerbMaps: + mapped, err := MappedDeleted(h.Proc, h.ProcRoot) + if err != nil { + return RootAnswer{Error: "reading the process table: " + err.Error()} + } + return RootAnswer{Mapped: mapped} + case VerbJournal: + if !ownUnit.MatchString(req.Unit) { + return refused("%q is not a transaction unit of the module", req.Unit) + } + if req.Lines < 1 || req.Lines > mostJournalLines { + return refused("a journal is read 1 to %d lines at a time, not %d", mostJournalLines, req.Lines) + } + r := h.Run(context.Background(), "journalctl", "--no-pager", "-o", "cat", "-n", strconv.Itoa(req.Lines), "-u", req.Unit) + if r.Status != 0 || r.Err != "" { + return RootAnswer{Error: failure("journalctl", "journalctl", r).Error()} + } + return RootAnswer{Lines: lines(r.Stdout)} + } + return refused("%q is not a verb of the root helper (%s, %s, %s, %s)", req.Verb, VerbTransaction, VerbRollback, VerbMaps, VerbJournal) +} + +// startUnit starts command as a transient unit of the service manager and answers once it started. +func (h *RootHelper) startUnit(unit, description string, command []string) RootAnswer { + args := append([]string{"--unit=" + unit, "--description=" + description + ", started by the mesh's pacman keeper", "--quiet"}, command...) + r := h.Run(context.Background(), "systemd-run", args...) + if r.Status != 0 || r.Err != "" { + return RootAnswer{Error: failure("systemd-run", "systemd-run", r).Error()} + } + return RootAnswer{} +} + +// peerCred is the account at the other end of a unix socket. +func peerCred(c net.Conn) (int, error) { + uc, ok := c.(*net.UnixConn) + if !ok { + return -1, errors.New("not a unix socket") + } + raw, err := uc.SyscallConn() + if err != nil { + return -1, err + } + uid := -1 + var gerr error + if err := raw.Control(func(fd uintptr) { + cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + if err != nil { + gerr = err + return + } + uid = int(cred.Uid) + }); err != nil { + return -1, err + } + return uid, gerr +} + +// Serve answers each connection on l, one request each, until ctx ends: a peer that is not the keeper's +// account is told so and nothing it sent is read. +func (h *RootHelper) Serve(ctx context.Context, l net.Listener) error { + go func() { <-ctx.Done(); l.Close() }() + peerOf := h.PeerOf + if peerOf == nil { + peerOf = peerCred + } + for { + c, err := l.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + go h.serveOne(c, peerOf) + } +} + +func (h *RootHelper) serveOne(c net.Conn, peerOf func(net.Conn) (int, error)) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(rootTimeout)) + answer := func(a RootAnswer) { _ = json.NewEncoder(c).Encode(a) } + uid, err := peerOf(c) + if err != nil || uid != h.Peer { + if h.Logf != nil { + h.Logf("[pacman] root helper: a connection from account %d refused: only the keeper's (%d) is answered", uid, h.Peer) + } + answer(refused("only the keeper's account is answered")) + return + } + var req RootRequest + if err := json.NewDecoder(io.LimitReader(c, 4<<20)).Decode(&req); err != nil { + answer(refused("no request: %v", err)) + return + } + a := h.Handle(req) + if h.Logf != nil { + said := "carried out" + if a.Error != "" { + said = a.Error + } + h.Logf("[pacman] root helper: %s %s %s: %s", req.Verb, req.Kind, req.Run+req.Unit, said) + } + answer(a) +} + +// rootClient is the keeper's side: the RootActs over the helper's socket. +type rootClient struct{ Socket string } + +func (c rootClient) ask(req RootRequest) (RootAnswer, error) { + conn, err := net.DialTimeout("unix", c.Socket, rootTimeout) + if err != nil { + return RootAnswer{}, fmt.Errorf("the root helper cannot be reached at %s: %w", c.Socket, err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(rootTimeout)) + if err := json.NewEncoder(conn).Encode(req); err != nil { + return RootAnswer{}, fmt.Errorf("asking the root helper: %w", err) + } + var a RootAnswer + if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&a); err != nil { + return RootAnswer{}, fmt.Errorf("the root helper's answer: %w", err) + } + if a.Error != "" { + return a, fmt.Errorf("the root helper: %s", a.Error) + } + return a, nil +} + +func (c rootClient) Transaction(kind, run string, ignore []string) error { + _, err := c.ask(RootRequest{Verb: VerbTransaction, Kind: kind, Run: run, Ignore: ignore}) + return err +} + +func (c rootClient) Rollback(run string, plan []RollbackItem) error { + _, err := c.ask(RootRequest{Verb: VerbRollback, Run: run, Plan: plan}) + return err +} + +func (c rootClient) Journal(unit string, n int) ([]string, error) { + a, err := c.ask(RootRequest{Verb: VerbJournal, Unit: unit, Lines: n}) + return a.Lines, err +} + +func (c rootClient) Maps() ([]Mapped, error) { + a, err := c.ask(RootRequest{Verb: VerbMaps}) + if a.Mapped == nil && err == nil { + a.Mapped = []Mapped{} + } + return a.Mapped, err +} + +// rootHelper is `pacman-tools root`: it listens on its socket, made the keeper's alone, until stopped. +func rootHelper() int { + logf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) } + if os.Getuid() != 0 { + logf("[pacman] root helper: it runs as root, and runs as account %d", os.Getuid()) + return 2 + } + sock, peerName := os.Getenv(EnvRootSocket), os.Getenv(EnvRootPeer) + if sock == "" || peerName == "" { + logf("[pacman] root helper was started without %s or %s", EnvRootSocket, EnvRootPeer) + return 2 + } + peer, err := user.Lookup(peerName) + if err != nil { + logf("[pacman] root helper: the keeper's account %s: %v", peerName, err) + return 1 + } + uid, err := strconv.Atoi(peer.Uid) + if err != nil { + logf("[pacman] root helper: account %s has no number: %v", peerName, err) + return 1 + } + self, err := os.Executable() + if err != nil { + logf("[pacman] root helper: its own program: %v", err) + return 1 + } + dir := filepath.Dir(sock) + if err := os.MkdirAll(dir, 0o711); err != nil || os.Chown(dir, 0, 0) != nil || os.Chmod(dir, 0o711) != nil { + logf("[pacman] root helper: its directory %s cannot be made root's: %v", dir, err) + return 1 + } + _ = os.Remove(sock) + l, err := net.Listen("unix", sock) + if err != nil { + logf("[pacman] root helper: listening on %s: %v", sock, err) + return 1 + } + if err := os.Chown(sock, uid, 0); err != nil || os.Chmod(sock, 0o600) != nil { + logf("[pacman] root helper: the socket cannot be made the keeper's alone: %v", err) + return 1 + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + h := &RootHelper{Run: ExecRunner, Proc: osProc{}, ProcRoot: "/proc", PlanDir: filepath.Join(dir, "plans"), Self: self, Peer: uid, Logf: logf} + logf("[pacman] root helper listening on %s for account %s (%d)", sock, peerName, uid) + if err := h.Serve(ctx, l); err != nil { + logf("[pacman] root helper stopped: %v", err) + return 1 + } + return 0 +} + +// machineArch is this machine's architecture as Arch Linux names it. +func machineArch() string { + switch runtime.GOARCH { + case "amd64": + return "x86_64" + case "arm64": + return "aarch64" + } + return runtime.GOARCH +} + +// archiveHas says whether the archive holds a file, by asking for its head. +func archiveHas(url string) bool { + c := &http.Client{Timeout: 30 * time.Second} + resp, err := c.Head(url) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// rollbackUnit carries out a rollback plan inside its unit: every package of a dated repository found by its +// file's name (this machine's architecture, then any), the plan's packages installed in one transaction, then +// the packages to remove removed. Nothing is changed when a package is not found. +func rollbackUnit(file, arch string, run func(string, ...string) int, probe func(string) bool, log io.Writer) int { + raw, err := os.ReadFile(file) + if err != nil { + fmt.Fprintf(log, "error: reading the plan: %v\n", err) + return 1 + } + var plan []RollbackItem + if err := json.Unmarshal(raw, &plan); err != nil { + fmt.Fprintf(log, "error: the plan is not one: %v\n", err) + return 1 + } + var targets, remove []string + for _, i := range plan { + if err := checkItem(i); err != nil { + fmt.Fprintf(log, "error: %v\n", err) + return 1 + } + switch i.Source { + case SourceRemoved: + remove = append(remove, i.Name) + case SourceRepository: + base := strings.Replace(i.Location, "$arch", arch, 1) + found := "" + for _, a := range []string{arch, "any"} { + if u := base + "/" + i.Name + "-" + i.To + "-" + a + ".pkg.tar.zst"; probe(u) { + found = u + break + } + } + if found == "" { + fmt.Fprintf(log, "error: %s %s is not in %s: nothing was changed\n", i.Name, i.To, base) + return 1 + } + targets = append(targets, found) + default: + targets = append(targets, i.Location) + } + } + if len(targets) > 0 { + if code := run("pacman", append([]string{"-U", "--noconfirm", "--"}, targets...)...); code != 0 { + return code + } + } + if len(remove) > 0 { + return run("pacman", append([]string{"-R", "--noconfirm", "--"}, remove...)...) + } + return 0 +} + +// rootRollback is `pacman-tools root-rollback `, the rollback unit's command: pacman's output goes to the +// unit's journal. +func rootRollback(args []string) int { + if len(args) != 1 || os.Getuid() != 0 { + fmt.Fprintln(os.Stderr, "error: root-rollback takes one plan file, and runs as root in its unit") + return 2 + } + run := func(name string, a ...string) int { + cmd := exec.Command(name, a...) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + cmd.Env = append(os.Environ(), "LC_ALL=C") + if err := cmd.Run(); err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) { + return exit.ExitCode() + } + fmt.Fprintf(os.Stderr, "error: %s: %v\n", name, err) + return 1 + } + return 0 + } + return rollbackUnit(args[0], machineArch(), run, archiveHas, os.Stderr) +} diff --git a/modules/pacman/cmd/pacman-tools/root_test.go b/modules/pacman/cmd/pacman-tools/root_test.go new file mode 100644 index 00000000..8d9d2962 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/root_test.go @@ -0,0 +1,312 @@ +package main + +// The root helper (novox/hq ADR 0510; acceptance 1841/AC10 as reworded on the issue): each listed verb with valid +// arguments is carried out; every other verb, a unit name outside the module's own, a package name with other +// characters and a run of no run's shape are refused; a connection from an account other than the keeper's is +// refused; the manifest declares `pacman-root` as root with no bus and places nothing under the sudo +// configuration. + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// rootBed is a helper over a recording runner, a stand-in process table and a plan directory of its own. +type rootBed struct { + h *RootHelper + ran [][]string +} + +func newRootBed(t *testing.T) *rootBed { + b := &rootBed{} + b.h = &RootHelper{ + Run: func(_ context.Context, name string, args ...string) Ran { + b.ran = append(b.ran, append([]string{name}, args...)) + if name == "journalctl" { + return Ran{Stdout: "error: failed to commit transaction\n"} + } + return Ran{} + }, + Proc: stubProc{"/proc": {"1"}, "/proc/1/maps": {"7f00 r-xp 0 0:1 1 /usr/lib/libc.so.6 (deleted)"}, "/proc/1/cgroup": {"0::/system.slice/sshd.service"}, "/proc/1/exe": {"/usr/bin/sshd"}}, + ProcRoot: "/proc", + PlanDir: t.TempDir(), + Self: "/var/lib/mesh/daemons/pacman-root/pacman-tools", + } + return b +} + +// stubProc is a process table in memory: a directory lists its names, a file holds its one line, a link its target. +type stubProc map[string][]string + +func (s stubProc) ReadDir(dir string) ([]string, error) { return s[dir], nil } +func (s stubProc) ReadFile(name string) ([]byte, error) { + v, ok := s[name] + if !ok { + return nil, os.ErrNotExist + } + return []byte(strings.Join(v, "\n")), nil +} +func (s stubProc) Readlink(name string) (string, error) { + v, ok := s[name] + if !ok { + return "", os.ErrNotExist + } + return v[0], nil +} + +const aRun = "20261024T040000Z" + +// testSocket is a socket in the abstract namespace, named for the test: a path under a test's temporary +// directory can pass the 108 bytes a socket's path may hold. +func testSocket(t *testing.T) string { + return fmt.Sprintf("@pacman-root-test-%d-%s", os.Getpid(), t.Name()) +} + +func TestTheRootHelperStartsAnUpgradeAsTheRunsUnit(t *testing.T) { + b := newRootBed(t) + a := b.h.Handle(RootRequest{Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"linux", "linux-headers"}}) + if a.Error != "" { + t.Fatalf("refused: %s", a.Error) + } + want := []string{"systemd-run", "--unit=" + TransactionUnit(RunUpgrade, aRun), "--description=pacman full upgrade of run " + aRun + ", started by the mesh's pacman keeper", + "--quiet", "pacman", "-Syu", "--noconfirm", "--ignore", "linux,linux-headers"} + if len(b.ran) != 1 || !reflect.DeepEqual(b.ran[0], want) { + t.Fatalf("ran %q, want %q", b.ran, want) + } +} + +func TestTheRootHelperStartsARollbackWithThePlanInTheRequest(t *testing.T) { + b := newRootBed(t) + plan := []RollbackItem{ + {Name: "bash", From: "5.3.20-1", To: "5.3.15-1", Source: SourceCache, Location: CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"}, + {Name: "glibc", From: "2.43-2", To: "2.43-1", Source: SourceArchive, Location: ArchiveURL + "/packages/g/glibc/glibc-2.43-1-x86_64.pkg.tar.zst"}, + {Name: "zlib", From: "1.3.2-1", To: "1.3.1-2", Source: SourceRepository, Location: ArchiveURL + "/repos/2026/10/04/core/os/$arch"}, + {Name: "made-up-library", From: "1.0-1", Source: SourceRemoved}, + } + a := b.h.Handle(RootRequest{Verb: VerbRollback, Run: aRun, Plan: plan}) + if a.Error != "" { + t.Fatalf("refused: %s", a.Error) + } + file := filepath.Join(b.h.PlanDir, aRun+".json") + want := []string{"systemd-run", "--unit=" + TransactionUnit(RunRollback, aRun), "--description=pacman rollback of run " + aRun + ", started by the mesh's pacman keeper", + "--quiet", b.h.Self, "root-rollback", file} + if len(b.ran) != 1 || !reflect.DeepEqual(b.ran[0], want) { + t.Fatalf("ran %q, want %q", b.ran, want) + } + raw, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + var got []RollbackItem + if err := json.Unmarshal(raw, &got); err != nil || !reflect.DeepEqual(got, plan) { + t.Fatalf("the plan file holds %s (%v)", raw, err) + } + if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 { + t.Errorf("the plan file is mode %v, want 0600: only root reads what root carries out", fi.Mode().Perm()) + } +} + +func TestTheRootHelperReadsTheReplacedFilesMappings(t *testing.T) { + b := newRootBed(t) + a := b.h.Handle(RootRequest{Verb: VerbMaps}) + want := []Mapped{{Unit: "sshd.service", Executable: "/usr/bin/sshd"}} + if a.Error != "" || !reflect.DeepEqual(a.Mapped, want) { + t.Fatalf("maps answered %+v, want %+v", a, want) + } +} + +func TestTheRootHelperReadsATransactionUnitsJournal(t *testing.T) { + b := newRootBed(t) + unit := TransactionUnit(RunUpgrade, aRun) + a := b.h.Handle(RootRequest{Verb: VerbJournal, Unit: unit, Lines: 40}) + if a.Error != "" || !reflect.DeepEqual(a.Lines, []string{"error: failed to commit transaction"}) { + t.Fatalf("journal answered %+v", a) + } + want := []string{"journalctl", "--no-pager", "-o", "cat", "-n", "40", "-u", unit} + if !reflect.DeepEqual(b.ran[0], want) { + t.Fatalf("ran %q, want %q", b.ran[0], want) + } +} + +func TestTheRootHelperRefusesEverythingOffItsList(t *testing.T) { + good := RollbackItem{Name: "bash", From: "5.3.20-1", To: "5.3.15-1", Source: SourceCache, Location: CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"} + with := func(f func(*RollbackItem)) []RollbackItem { i := good; f(&i); return []RollbackItem{i} } + cases := map[string]RootRequest{ + "another verb": {Verb: "shell", Run: aRun}, + "an empty verb": {}, + "another kind of transaction": {Verb: VerbTransaction, Kind: "remove", Run: aRun}, + "a rollback as a transaction": {Verb: VerbTransaction, Kind: RunRollback, Run: aRun}, + "a run of no run's shape": {Verb: VerbTransaction, Kind: RunUpgrade, Run: "../../etc"}, + "a run with an option in it": {Verb: VerbTransaction, Kind: RunUpgrade, Run: "--now"}, + "an ignored name with a space": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"linux headers"}}, + "an ignored name as an option": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"--root=/tmp"}}, + "an ignored name with a comma": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"linux,glibc"}}, + "an ignored name in capitals": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"Linux"}}, + "a journal of another unit": {Verb: VerbJournal, Unit: "sshd.service", Lines: 10}, + "a journal of a look-alike unit": {Verb: VerbJournal, Unit: "mesh-pacman-upgrade-x;reboot", Lines: 10}, + "a journal of too many lines": {Verb: VerbJournal, Unit: TransactionUnit(RunUpgrade, aRun), Lines: 100000}, + "a rollback of no plan": {Verb: VerbRollback, Run: aRun}, + "a rollback of a bad run": {Verb: VerbRollback, Run: "x", Plan: []RollbackItem{good}}, + "a plan item with a bad name": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Name = "bash;rm" })}, + "a plan item with a bad version": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.To = "1 2" })}, + "a cache file outside the cache": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Location = "/tmp/bash-5.3.15-1-x86_64.pkg.tar.zst" })}, + "a cache file climbing out": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Location = CacheDir + "/../../../tmp/bash-5.3.15-1-x86_64.pkg.tar.zst" })}, + "a cache file of another package": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Location = CacheDir + "/evil-1-1-x86_64.pkg.tar.zst" })}, + "an archive URL elsewhere": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { + i.Source, i.Location = SourceArchive, "https://example.org/packages/b/bash/bash-5.3.15-1-x86_64.pkg.tar.zst" + })}, + "a repository URL elsewhere": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { + i.Source, i.Location = SourceRepository, ArchiveURL+"/repos/2026/10/04/../../../../x" + })}, + "another source": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Source = "file" })}, + "a removal with a location": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { + i.Source, i.To = SourceRemoved, "" + })}, + } + for name, req := range cases { + b := newRootBed(t) + if a := b.h.Handle(req); a.Error == "" { + t.Errorf("%s: carried out (%+v)", name, a) + } + if len(b.ran) != 0 { + t.Errorf("%s: ran %q although refused", name, b.ran) + } + } +} + +// serveOnce starts the helper on a socket of a test directory, allowing peer, and answers one request. +func serveOnce(t *testing.T, peer int, req RootRequest) (RootAnswer, error) { + t.Helper() + b := newRootBed(t) + b.h.Peer = peer + sock := testSocket(t) + l, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go func() { _ = b.h.Serve(ctx, l) }() + return rootClient{Socket: sock}.ask(req) +} + +func TestTheRootHelperAnswersTheKeepersAccountOverItsSocket(t *testing.T) { + a, err := serveOnce(t, os.Getuid(), RootRequest{Verb: VerbMaps}) + if err != nil || a.Error != "" || len(a.Mapped) != 1 { + t.Fatalf("the keeper's own account was answered %+v, %v", a, err) + } +} + +func TestTheRootHelperRefusesAnyOtherAccount(t *testing.T) { + a, err := serveOnce(t, os.Getuid()+1, RootRequest{Verb: VerbMaps}) + if err == nil && a.Error == "" { + t.Fatalf("another account was answered: %+v", a) + } + if a.Mapped != nil { + t.Fatalf("another account was handed the mappings: %+v", a) + } +} + +func TestTheClientCarriesTheRootActsAndSaysARefusal(t *testing.T) { + b := newRootBed(t) + b.h.Peer = os.Getuid() + sock := testSocket(t) + l, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = b.h.Serve(ctx, l) }() + var acts RootActs = rootClient{Socket: sock} + if err := acts.Transaction(RunUpgrade, aRun, nil); err != nil { + t.Fatalf("transaction: %v", err) + } + if err := acts.Transaction(RunUpgrade, "bad", nil); err == nil || !strings.Contains(err.Error(), "run") { + t.Fatalf("a bad run was not refused by name: %v", err) + } + if lines, err := acts.Journal(TransactionUnit(RunUpgrade, aRun), 5); err != nil || len(lines) != 1 { + t.Fatalf("journal: %q, %v", lines, err) + } + if _, err := (rootClient{Socket: testSocket(t) + "-none"}).Maps(); err == nil || !strings.Contains(err.Error(), "root helper") { + t.Fatalf("a helper that is not there is not said: %v", err) + } +} + +func TestTheRootHelperIsRootWithNoBusAndNoSudoRule(t *testing.T) { + m := manifest(t) + r := m.resource(t, "root-helper") + if r["type"] != "process" || r["name"] != "pacman-root" || r["artifact"] != "tools" || + strings.Join(strs(r["run"]), " ") != "./"+binaryName+" root" { + t.Fatalf("the root helper: %v", r) + } + if _, ok := r["user"]; ok { + t.Errorf("the root helper runs as root (ADR 0510 §1, §3), and names a user: %v", r["user"]) + } + if _, ok := r["bus"]; ok { + t.Errorf("the root helper has no bus account (ADR 0510 §1): %v", r["bus"]) + } + env, _ := r["env"].(map[string]any) + if env[EnvRootSocket] != RootSocketPath || env[EnvRootPeer] != "pacman-keeper" { + t.Errorf("the root helper's environment names its socket and the keeper's account: %v", env) + } + k := m.resource(t, "keeper") + kenv, _ := k["env"].(map[string]any) + if kenv[EnvRootSocket] != RootSocketPath { + t.Errorf("the keeper is told the helper's socket: %v", kenv) + } + for _, res := range m.Resources { + if p, _ := res["path"].(string); strings.Contains(p, "sudoers") { + t.Errorf("a file under the sudo configuration is placed: %s (ADR 0510 §1)", p) + } + } +} + +func TestTheRollbackUnitInstallsThePlanThenRemovesWhatWasAskedInOrder(t *testing.T) { + dir := t.TempDir() + plan := []RollbackItem{ + {Name: "bash", To: "5.3.15-1", Source: SourceCache, Location: CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"}, + {Name: "zlib", To: "1.3.1-2", Source: SourceRepository, Location: ArchiveURL + "/repos/2026/10/04/core/os/$arch"}, + {Name: "made-up-library", From: "1.0-1", Source: SourceRemoved}, + } + raw, _ := json.Marshal(plan) + file := filepath.Join(dir, aRun+".json") + if err := os.WriteFile(file, raw, 0o600); err != nil { + t.Fatal(err) + } + var ran [][]string + run := func(name string, args ...string) int { ran = append(ran, append([]string{name}, args...)); return 0 } + probed := []string{} + probe := func(url string) bool { probed = append(probed, url); return strings.HasSuffix(url, "-any.pkg.tar.zst") } + if code := rollbackUnit(file, "x86_64", run, probe, os.Stderr); code != 0 { + t.Fatalf("exit %d", code) + } + want := [][]string{ + {"pacman", "-U", "--noconfirm", "--", CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst", ArchiveURL + "/repos/2026/10/04/core/os/x86_64/zlib-1.3.1-2-any.pkg.tar.zst"}, + {"pacman", "-R", "--noconfirm", "--", "made-up-library"}, + } + if !reflect.DeepEqual(ran, want) { + t.Fatalf("ran %q, want %q", ran, want) + } + if len(probed) != 2 || !strings.HasSuffix(probed[0], "-x86_64.pkg.tar.zst") { + t.Errorf("the machine's architecture is tried first, then any: %q", probed) + } +} + +func TestTheRollbackUnitChangesNothingWhenAPackageIsNotFound(t *testing.T) { + file := filepath.Join(t.TempDir(), aRun+".json") + raw, _ := json.Marshal([]RollbackItem{{Name: "zlib", To: "1.3.1-2", Source: SourceRepository, Location: ArchiveURL + "/repos/2026/10/04/core/os/$arch"}}) + _ = os.WriteFile(file, raw, 0o600) + var ran [][]string + run := func(name string, args ...string) int { ran = append(ran, append([]string{name}, args...)); return 0 } + if code := rollbackUnit(file, "x86_64", run, func(string) bool { return false }, &strings.Builder{}); code == 0 || len(ran) != 0 { + t.Fatalf("exit %d, ran %q: a package the archive does not hold fails the unit before any change", code, ran) + } +} diff --git a/modules/pacman/cmd/pacman-tools/round_integration_test.go b/modules/pacman/cmd/pacman-tools/round_integration_test.go new file mode 100644 index 00000000..a6855d9b --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/round_integration_test.go @@ -0,0 +1,180 @@ +//go:build integration + +package main + +// The round over a real bus (novox/hq ADR 0508 §5, §6; acceptance 1841/AC15): the keepers of two made-up nodes, +// each whole (its own bus account, its states, its watches) against a JetStream server of the test's own, with +// fake machines and a fake root helper each. The first node of the order opens the week's round and runs; the +// second starts only once the first's after-upgrade check passed; a failed check stops the round there. +// Compiled by the merge gate's `go vet -tags integration`; run by the merge check, never by hand. + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/process" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// countingRoot is a node's root helper: it counts the transactions it was asked to start. +type countingRoot struct { + mu sync.Mutex + started []string +} + +func (r *countingRoot) Transaction(kind, run string, ignore []string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.started = append(r.started, kind+" "+run) + return nil +} +func (r *countingRoot) Rollback(string, []RollbackItem) error { return nil } +func (r *countingRoot) Journal(string, int) ([]string, error) { return nil, nil } +func (r *countingRoot) Maps() ([]Mapped, error) { return []Mapped{}, nil } +func (r *countingRoot) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.started) +} + +// roundNode is one made-up node's keeper on the test's bus. +type roundNode struct { + root *countingRoot + signals chan Signal +} + +// startRoundNode starts node's keeper; its machine's clock begins at the Saturday's opening, its transaction +// unit is gone (ended well) whenever asked, and its after-upgrade check passes unless passes says otherwise. +func startRoundNode(t *testing.T, ctx context.Context, url, node string, order []string, passes bool) *roundNode { + t.Helper() + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\nzsh 5.9-1\n", "zsh\n") + base := db.runner() + run := func(c context.Context, name string, args ...string) Ran { + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + switch { + case strings.HasPrefix(line, "systemctl list-units --state=failed"), line == "dkms status": + return Ran{} + case strings.HasPrefix(line, "systemctl show "+unitPrefix): + return Ran{Stdout: "LoadState=not-found\n"} + } + return base(c, name, args...) + } + m := machine(run, 1000) + began := time.Now() + m.Now = func() time.Time { return t0.Add(time.Since(began)) } + m.ReadFile = func(path string) ([]byte, error) { + if path == OSRelease { + return []byte("7.2.8-arch1-2\n"), nil + } + return nil, errNoFile + } + n := &roundNode{root: &countingRoot{}, signals: make(chan Signal)} + k := &Keeper{Config: KeeperConfig{UpgradeOrder: order, RecordDays: 180, RoundStartMinutes: 120}, Machine: m, Clock: realClock{}, + Watcher: fakeWatcher{n.signals}, Logf: func(f string, a ...any) { t.Logf(node+": "+f, a...) }, MakeHooks: keeperHooks, + Root: n.root, Check: func(Run, *Record) Check { return Check{Passed: passes} }} + account := process.Account{URL: url, Node: node, Module: "pacman", User: node + ".pacman.keeper", Password: "pw"} + go func() { _ = runKeeper(ctx, account, k) }() + return n +} + +func roundBus(t *testing.T) (string, map[string]jetstream.KeyValue) { + t.Helper() + url := newTestBus(t) + nc, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + buckets := map[string]jetstream.KeyValue{} + for _, name := range States { + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "pacman_" + name, History: 8}) + if err != nil { + t.Fatal(err) + } + buckets[name] = kv + } + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "pacman-events", Subjects: []string{"mesh.mod.pacman.event.>"}}); err != nil { + t.Fatal(err) + } + return url, buckets +} + +func eventually(t *testing.T, what string, within time.Duration, ok func() bool) { + t.Helper() + for deadline := time.Now().Add(within); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if ok() { + return + } + } + t.Fatalf("never: %s", what) +} + +func roundNodeOnBus(kv jetstream.KeyValue, round, node string) RoundNode { + var rn RoundNode + if e, err := kv.Get(context.Background(), RoundNodeKey(round, node)); err == nil { + _ = json.Unmarshal(e.Value(), &rn) + } + return rn +} + +func TestTheRoundGoesNodeByNodeOnARealBus(t *testing.T) { + url, buckets := roundBus(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + order := []string{"node-a", "node-b"} + a := startRoundNode(t, ctx, url, "node-a", order, true) + b := startRoundNode(t, ctx, url, "node-b", order, true) + round := RoundID(t0) + + eventually(t, "node-a opened the round and started its run", 20*time.Second, func() bool { + return a.root.count() == 1 && roundNodeOnBus(buckets[StateRounds], round, "node-a").Run != "" + }) + time.Sleep(time.Second) + if b.root.count() != 0 { + t.Fatalf("node-b started before node-a passed: %v", b.root.started) + } + // node-a's transaction ends: its lock is released, its run is finished and checked. + a.signals <- LockReleased + eventually(t, "node-b started once node-a passed", 20*time.Second, func() bool { + return b.root.count() == 1 && roundNodeOnBus(buckets[StateRounds], round, "node-b").Run != "" + }) + if a.root.count() != 1 { + t.Errorf("node-a ran again: %v", a.root.started) + } +} + +func TestAFailedCheckStopsTheRoundOnARealBus(t *testing.T) { + url, buckets := roundBus(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + order := []string{"node-a", "node-b"} + a := startRoundNode(t, ctx, url, "node-a", order, false) + b := startRoundNode(t, ctx, url, "node-b", order, true) + round := RoundID(t0) + eventually(t, "node-a started its run", 20*time.Second, func() bool { return a.root.count() == 1 }) + a.signals <- LockReleased + runA := "" + eventually(t, "node-a's run checked", 20*time.Second, func() bool { + runA = roundNodeOnBus(buckets[StateRounds], round, "node-a").Run + if runA == "" { + return false + } + e, err := buckets[StateRuns].Get(context.Background(), NodeKey("node-a", runA)) + return err == nil && strings.Contains(string(e.Value()), `"checked-after"`) + }) + time.Sleep(2 * time.Second) + if b.root.count() != 0 || roundNodeOnBus(buckets[StateRounds], round, "node-b").Run != "" { + t.Fatalf("node-b started after node-a's check failed: %v", b.root.started) + } +} diff --git a/modules/pacman/cmd/pacman-tools/rounds.go b/modules/pacman/cmd/pacman-tools/rounds.go new file mode 100644 index 00000000..dcb10823 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/rounds.go @@ -0,0 +1,565 @@ +package main + +// The week's round (novox/hq ADR 0508 §5, §6): the nodes upgrade one at a time in the order of the mesh-level key +// `upgrade-order`, each after the node before it passed its after-upgrade check. On Saturday at 04:00 local time +// the first node of the order opens the week's round in `rounds` (a key created once, so one round a week); every +// node's keeper watches `rounds` and `runs` and starts its own run when its turn came. A failed run or check +// stops the round at that node. Only the operator passes a node: `pacman_round` with pass and a reason asks the +// operator through the operator's channel (ADR 0259), and the keeper of the node waiting on it lets the round go +// on only on the operator's Approve, read from the channel's own record. With no order, no round opens. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// Module is this module's name: the asker of its asks on the operator's channel. +const Module = "pacman" + +// MessengerAsksBucket is the operator channel's records bucket, where the keeper reads the answer to a pass. +const MessengerAsksBucket = "messenger_asks" + +// The operator's answers to a pass, besides PassApproved and PassDeclined: an ask nobody answered in time. +const PassExpired = "expired" + +// passLasts is how long a pass waits for the operator's answer. +const passLasts = 12 * time.Hour + +// refusedRetry is how long after a run of the round was refused (it started nothing) the node tries again. +const refusedRetry = time.Hour + +// passCheckEvery is how often a keeper reads the channel's record of a pass that waits for its answer. +const passCheckEvery = time.Minute + +// defaultRoundStartMinutes is round-start-minutes' default in the manifest. +const defaultRoundStartMinutes = 120 + +// RoundOpening is the Saturday at 04:00 of the ISO week that holds at, in at's own time zone. +func RoundOpening(at time.Time) time.Time { + day := int(at.Weekday()+6) % 7 // Monday 0 … Sunday 6 + monday := time.Date(at.Year(), at.Month(), at.Day()-day, 0, 0, 0, 0, at.Location()) + return monday.AddDate(0, 0, 5).Add(4 * time.Hour) +} + +// PassOperatorAsk is the ask on the operator's channel for a pass: composed from the pass alone, so the tool that +// publishes it and the keeper that reads its answer compose the same ask, with the same digest. +func PassOperatorAsk(round, node, next, reason, id string, at time.Time) asks.Ask { + headline := "Upgrade round: pass " + node + if len([]rune(headline)) > asks.HeadlineLength { + headline = "Upgrade round: pass a node" + } + binds, _ := asks.ActDigest(passAct(round, node, reason, id)) + return asks.Ask{ + ID: id, + Headline: headline, + Explanation: fmt.Sprintf("The week's upgrade round %s stands at %s. Passing it lets %s and the nodes after it upgrade; "+ + "%s is not upgraded in this round.\nThe reason given: %s", round, node, next, node, reason), + Options: []asks.Option{ + {ID: "pass", Label: "Pass it", Does: "the round goes on past " + node, Level: asks.Approve, Binds: binds}, + {ID: "keep", Label: "Keep it waiting", Does: "the round stays at " + node, Level: asks.Acknowledge}, + }, + Who: asks.Operator, + Expires: at.UTC().Add(passLasts), + OnExpiry: "the round stays at " + node, + } +} + +func passAct(round, node, reason, id string) asks.Act { + return asks.Act{"verb": Module + ".pass", "round": round, "node": node, "reason": reason, "ask": id} +} + +// roundView is one round as the bus holds it: the round, each node's place, and each node's run in it. +type roundView struct { + Round Round + Nodes map[string]RoundNode + revs map[string]uint64 + Runs map[string]Run +} + +// latestRound is the newest round's id, or none. +func latestRound(rounds Bucket) (string, error) { + keys, err := rounds.Keys() + if err != nil { + return "", fmt.Errorf("listing rounds: %w", err) + } + latest := "" + for _, k := range keys { + if !strings.Contains(k, ".") && k > latest { + latest = k + } + } + return latest, nil +} + +func readRound(rounds, runs Bucket, id string) (*roundView, error) { + e, err := rounds.Get(id) + if err != nil || e == nil { + return nil, err + } + v := &roundView{Nodes: map[string]RoundNode{}, revs: map[string]uint64{}, Runs: map[string]Run{}} + if err := json.Unmarshal(e.Value, &v.Round); err != nil { + return nil, fmt.Errorf("round %s: %w", id, err) + } + for _, node := range v.Round.Order { + e, err := rounds.Get(RoundNodeKey(id, node)) + if err != nil { + return nil, err + } + if e == nil { + continue + } + var rn RoundNode + if err := json.Unmarshal(e.Value, &rn); err != nil { + return nil, fmt.Errorf("round %s, node %s: %w", id, node, err) + } + v.Nodes[node], v.revs[node] = rn, e.Revision + if rn.Run == "" { + continue + } + re, err := runs.Get(NodeKey(node, rn.Run)) + if err != nil { + return nil, err + } + if re != nil { + var r Run + if err := json.Unmarshal(re.Value, &r); err != nil { + return nil, fmt.Errorf("run %s of %s: %w", rn.Run, node, err) + } + v.Runs[node] = r + } + } + return v, nil +} + +// checkedAt is when a run's after-upgrade check passed, or zero. +func checkedAt(r Run) time.Time { + for _, s := range r.Steps { + if s.Step == StepCheckedAfter && s.Check != nil && s.Check.Passed { + return s.At + } + } + return time.Time{} +} + +// passedAt is when a node let the round go on: its check passed, or the operator approved its pass; zero when +// it has not. +func (v *roundView) passedAt(node string) time.Time { + if rn := v.Nodes[node]; rn.Pass != nil && rn.Pass.Answer == PassApproved && rn.Pass.Answered != nil { + return *rn.Pass.Answered + } + if r, ok := v.Runs[node]; ok { + return checkedAt(r) + } + return time.Time{} +} + +// turnSince is when a node's turn came: the round's opening for the first, the passing of the node before it +// for the rest; zero when it has not come. +func (v *roundView) turnSince(node string) time.Time { + for i, n := range v.Round.Order { + if n != node { + continue + } + if i == 0 { + return v.Round.Opened + } + return v.passedAt(v.Round.Order[i-1]) + } + return time.Time{} +} + +// next is the node after node in the order, or none. +func (v *roundView) next(node string) string { + for i, n := range v.Round.Order { + if n == node && i+1 < len(v.Round.Order) { + return v.Round.Order[i+1] + } + } + return "" +} + +// putRoundNode writes a node's place in a round from the revision read, so a pass and a run written by two +// keepers never overwrite each other: a lost race is read again and changed again. +func putRoundNode(rounds Bucket, round, node string, change func(*RoundNode)) error { + key := RoundNodeKey(round, node) + for attempt := 0; attempt < 5; attempt++ { + e, err := rounds.Get(key) + if err != nil { + return err + } + rn := RoundNode{Round: round, Node: node} + if e != nil { + if err := json.Unmarshal(e.Value, &rn); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + change(&rn) + _, err = rounds.Update(key, rn, e.Revision) + } else { + change(&rn) + _, err = rounds.Create(key, rn) + } + if !errors.Is(err, stdio.ErrChanged) { + return err + } + } + return fmt.Errorf("%s was written by another keeper five times in a row", key) +} + +// RoundStep does what the node's keeper has to do for the rounds now, and answers when it should be called +// again (zero: only when `rounds` or `runs` change). The keeper calls it at its start, at the time it answered, +// and whenever `rounds` or `runs` change. The first node of the order opens the week's round once it is +// Saturday 04:00; a node whose turn came starts its run; a node waiting on a pass reads the operator's answer. +func (u *Upgrades) RoundStep() (time.Time, error) { + now := u.Machine.Now() + order := u.Config.UpgradeOrder + var next time.Time + soonest := func(t time.Time) { + if !t.IsZero() && (next.IsZero() || t.Before(next)) { + next = t + } + } + if len(order) == 0 { + return time.Time{}, nil // no order: no round (the health says so) + } + var errs []error + if order[0] == u.Node { + opening := RoundOpening(now) + if now.Before(opening) { + soonest(opening) + } else { + soonest(opening.AddDate(0, 0, 7)) + id := RoundID(now) + round := Round{Round: id, Opened: now.UTC(), OpenedBy: u.Node, Order: append([]string(nil), order...)} + if _, err := u.Rounds.Create(id, round); err != nil && !errors.Is(err, stdio.ErrChanged) { + errs = append(errs, fmt.Errorf("opening round %s: %w", id, err)) + } + } + } + id, err := latestRound(u.Rounds) + if err != nil || id == "" { + return next, errors.Join(append(errs, err)...) + } + v, err := readRound(u.Rounds, u.Runs, id) + if err != nil || v == nil { + return next, errors.Join(append(errs, err)...) + } + // A pass of the node before this one that waits for the operator's answer. + for i, node := range v.Round.Order { + if i+1 >= len(v.Round.Order) || v.Round.Order[i+1] != u.Node { + continue + } + if rn := v.Nodes[node]; rn.Pass != nil && rn.Pass.Answer == "" { + answered, err := u.readPass(v, node) + if err != nil { + errs = append(errs, err) + } + if !answered { + soonest(now.Add(passCheckEvery)) + } else if v, err = readRound(u.Rounds, u.Runs, id); err != nil { + return next, errors.Join(append(errs, err)...) + } + } + } + t, err := u.takeTurn(v, now) + soonest(t) + return next, errors.Join(append(errs, err)...) +} + +// takeTurn starts this node's run in the round when its turn came and it has none, and again an hour after a +// run of it was refused (it started nothing); it answers when to look again. +func (u *Upgrades) takeTurn(v *roundView, now time.Time) (time.Time, error) { + if !contains(v.Round.Order, u.Node) || v.turnSince(u.Node).IsZero() || !v.passedAt(u.Node).IsZero() { + return time.Time{}, nil + } + if rn := v.Nodes[u.Node]; rn.Pass != nil && rn.Pass.Answer == "" { + return time.Time{}, nil // the operator is asked to pass this node: it waits for the answer + } + own, err := u.ownRuns() + if err != nil { + return time.Time{}, err + } + var latest *Run + for i := range own { + if own[i].Round == v.Round.Round && (latest == nil || own[i].Run.Run > latest.Run) { + latest = &own[i].Run + } + } + if latest != nil { + if v.Nodes[u.Node].Run != latest.Run { + // a keeper that went away between starting its run and naming it in the round + if err := putRoundNode(u.Rounds, v.Round.Round, u.Node, func(rn *RoundNode) { rn.Run = latest.Run }); err != nil { + return time.Time{}, err + } + } + if lastStep(*latest) != StepRefused { + return time.Time{}, nil // running, or ended: a failure stops the round here + } + retry := latest.Steps[len(latest.Steps)-1].At.Add(refusedRetry) + if now.Before(retry) { + return retry, nil + } + } + r, err := u.StartUpgrade(FromRound, v.Round.Round, "") + if len(r.Steps) == 0 { + return time.Time{}, err + } + if perr := putRoundNode(u.Rounds, v.Round.Round, u.Node, func(rn *RoundNode) { rn.Run = r.Run }); perr != nil { + err = errors.Join(err, perr) + } + if lastStep(r) == StepRefused { + return r.Steps[len(r.Steps)-1].At.Add(refusedRetry), err + } + return time.Time{}, err +} + +// readPass reads the operator's answer to a pass of node from the channel's record, and writes it into the +// round once there is one. It answers whether the pass is answered. +func (u *Upgrades) readPass(v *roundView, node string) (bool, error) { + rn := v.Nodes[node] + p := rn.Pass + if u.AskRecord == nil { + return false, errors.New("no reader of the operator channel's records: a pass cannot be answered") + } + e, err := u.AskRecord(p.Ask) + if err != nil { + return false, fmt.Errorf("reading the operator's answer to the pass of %s: %w", node, err) + } + if e == nil { + return false, nil + } + var rec struct { + State string `json:"state"` + Warrant *asks.Warrant `json:"warrant"` + } + if err := json.Unmarshal(e.Value, &rec); err != nil { + return false, fmt.Errorf("the channel's record of the pass of %s: %w", node, err) + } + if rec.State == "" || rec.State == "open" || rec.Warrant == nil { + return false, nil + } + w := *rec.Warrant + answer := PassDeclined + switch { + case w.Outcome == asks.OutcomeExpired: + answer = PassExpired + case w.Outcome == asks.OutcomeChosen: + ask := PassOperatorAsk(v.Round.Round, node, v.next(node), p.Reason, p.Ask, p.Asked) + option, err := w.For(Module, ask) + if err == nil { + err = option.Performs(passAct(v.Round.Round, node, p.Reason, p.Ask)) + } + if err != nil { + return false, fmt.Errorf("the answer to the pass of %s is not the operator's to this pass: %w", node, err) + } + if option.ID == "pass" { + answer = PassApproved + } + } + at := u.Machine.Now().UTC() + return true, putRoundNode(u.Rounds, v.Round.Round, node, func(rn *RoundNode) { + if rn.Pass != nil && rn.Pass.Ask == p.Ask && rn.Pass.Answer == "" { + rn.Pass.Answer, rn.Pass.Answered = answer, &at + } + }) +} + +// takePass is a pass ask taken by the keeper of the node waiting on the passed node: the pass is written into +// the round, waiting for the operator's answer (the tool asked the operator). +func (u *Upgrades) takePass(a Ask) error { + v, err := readRound(u.Rounds, u.Runs, a.Round) + if err != nil { + return err + } + switch { + case v == nil: + return fmt.Errorf("ask %s passes %s in round %s, which is not open", a.ID, a.Pass, a.Round) + case v.next(a.Pass) != u.Node: + return fmt.Errorf("ask %s passes %s, on which %s does not wait", a.ID, a.Pass, u.Node) + } + return putRoundNode(u.Rounds, a.Round, a.Pass, func(rn *RoundNode) { + if rn.Pass == nil || rn.Pass.Ask != a.ID { // taken again after a restart: the answer already read stays + rn.Pass = &Pass{Reason: a.Reason, Ask: a.ID, Asked: a.At.UTC()} + } + }) +} + +// NodeInRound is where one node stands in a round, as pacman_round and the health say it. +type NodeInRound struct { + Node string `json:"node"` + State string `json:"state"` + Run string `json:"run,omitempty"` + Since *time.Time `json:"since,omitempty"` + WaitsFor string `json:"waits_for,omitempty"` + Pass *Pass `json:"pass,omitempty"` +} + +// The states of a node in a round. +const ( + InRoundWaiting = "waiting" // the node before it has not passed + InRoundTurn = "turn" // its turn came, its run has not started + InRoundStalled = "stalled" // its turn came longer than round-start-minutes ago, and it has not started + InRoundRunning = "running" // its run goes on + InRoundPassed = "passed" // its check passed, or the operator passed it + InRoundFailed = "failed" // its run failed or its check did not pass: the round stops here + InRoundRefused = "refused" // its run did not start (it is tried again) + InRoundOff = "off-order" // not in the round's order +) + +// RoundState is where every node of the order stands in a round at now. +func RoundState(v *roundView, now time.Time, startMinutes int) []NodeInRound { + if startMinutes < 1 { + startMinutes = defaultRoundStartMinutes + } + out := []NodeInRound{} + for i, node := range v.Round.Order { + s := NodeInRound{Node: node, Run: v.Nodes[node].Run, Pass: v.Nodes[node].Pass} + at := func(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + t = t.UTC() + return &t + } + r, hasRun := v.Runs[node] + switch { + case !v.passedAt(node).IsZero(): + s.State, s.Since = InRoundPassed, at(v.passedAt(node)) + case hasRun && lastStep(r) == StepRefused: + s.State, s.Since = InRoundRefused, at(r.Steps[len(r.Steps)-1].At) + case hasRun && lastStep(r) == StepCheckedAfter: + s.State, s.Since = InRoundFailed, at(r.Steps[len(r.Steps)-1].At) + case hasRun: + s.State = InRoundRunning + if len(r.Steps) > 0 { + s.Since = at(r.Steps[0].At) + } + case !v.turnSince(node).IsZero(): + since := v.turnSince(node) + s.State, s.Since = InRoundTurn, at(since) + if now.Sub(since) > time.Duration(startMinutes)*time.Minute { + s.State = InRoundStalled + } + default: + s.State = InRoundWaiting + for j := i - 1; j >= 0; j-- { + if v.passedAt(v.Round.Order[j]).IsZero() { + s.WaitsFor = v.Round.Order[j] + } + } + if s.WaitsFor != "" { + // since the node it waits for stopped or its turn came + prev := out[indexOf(v.Round.Order, s.WaitsFor)] + s.Since = prev.Since + } + } + out = append(out, s) + } + return out +} + +func indexOf(list []string, s string) int { + for i, x := range list { + if x == s { + return i + } + } + return -1 +} + +// seatPublish publishes on a seat for the tools bundle; a test replaces it. +var seatPublish = stdio.SeatPublish + +// ListRounds answers pacman_round's read: the newest rounds, or one, each with every node's state. +func ListRounds(rounds, runs Bucket, round string, now time.Time, startMinutes, limit int) (map[string]any, error) { + keys, err := rounds.Keys() + if err != nil { + return nil, err + } + var ids []string + for _, k := range keys { + if !strings.Contains(k, ".") && (round == "" || k == round) { + ids = append(ids, k) + } + } + if round != "" && len(ids) == 0 { + return nil, fmt.Errorf("no round %s", round) + } + sort.Sort(sort.Reverse(sort.StringSlice(ids))) + if len(ids) > limit { + ids = ids[:limit] + } + listed := []map[string]any{} + for _, id := range ids { + v, err := readRound(rounds, runs, id) + if err != nil || v == nil { + return nil, err + } + listed = append(listed, map[string]any{"round": id, "opened": v.Round.Opened, "opened_by": v.Round.OpenedBy, + "order": v.Round.Order, "nodes": RoundState(v, now, startMinutes)}) + } + return map[string]any{"rounds": listed}, nil +} + +// AskPass asks the operator to let a round go on past a node: the ask on the operator's channel, and the ask in +// `asks` for the keeper of the node waiting on it, which reads the answer. A reason is required. +func AskPass(rounds, runs, asksState Bucket, round, node, reason string, now time.Time) (map[string]any, error) { + if strings.TrimSpace(reason) == "" { + return nil, errors.New("a pass needs a reason: the operator is asked to let the round go on past a node, and is told why") + } + if round == "" { + id, err := latestRound(rounds) + if err != nil { + return nil, err + } + round = id + } + v, err := readRound(rounds, runs, round) + if err != nil { + return nil, err + } + if v == nil { + return nil, fmt.Errorf("no round %s is open", round) + } + if !contains(v.Round.Order, node) { + return nil, fmt.Errorf("%s is not in round %s's order (%s)", node, round, strings.Join(v.Round.Order, ", ")) + } + if !v.passedAt(node).IsZero() { + return nil, fmt.Errorf("%s has passed already in round %s", node, round) + } + next := v.next(node) + if next == "" { + return nil, fmt.Errorf("%s is the last of the order: no node waits on it", node) + } + a := Ask{ID: AskID(now), Node: next, Act: ActPass, At: now.UTC(), Round: round, Pass: node, Reason: reason} + opAsk := PassOperatorAsk(round, node, next, reason, a.ID, a.At) + if err := opAsk.Check(now); err != nil { + return nil, err + } + if err := seatPublish(asks.AskSubject(Module), opAsk, opAsk.ID); err != nil { + return nil, fmt.Errorf("the operator could not be asked: %w; nothing was passed", err) + } + if _, err := asksState.Create(NodeKey(next, a.ID), a); err != nil { + return nil, fmt.Errorf("the operator was asked, and the ask for %s's keeper could not be written: %w; an Approve passes nothing", next, err) + } + return map[string]any{"asked": opAsk.ID, "round": round, "pass": node, "waiting": next, + "next": "the round goes on past " + node + " only when the operator approves on their channel; pacman_round says where it stands"}, nil +} + +// roundStartMinutes is round-start-minutes as the tools bundle reads it: from the keeper's config file, which +// the manifest names to the bundle too (EnvConfig); its default when the file cannot be read. +var roundStartMinutes = func() int { + raw, err := os.ReadFile(os.Getenv(EnvConfig)) + var c KeeperConfig + if err != nil || json.Unmarshal(raw, &c) != nil || c.RoundStartMinutes < 1 { + return defaultRoundStartMinutes + } + return c.RoundStartMinutes +} diff --git a/modules/pacman/cmd/pacman-tools/rounds_test.go b/modules/pacman/cmd/pacman-tools/rounds_test.go new file mode 100644 index 00000000..26a54748 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/rounds_test.go @@ -0,0 +1,305 @@ +package main + +// The week's round (novox/hq ADR 0508 §5, §6; acceptance 1841/AC6 and 1841/AC7), for three made-up nodes over +// states in memory. + +import ( + "encoding/json" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// roundBed is three keepers over one bed's states, each with the order node-a, node-b, node-c. +type roundBed struct { + *keeperBed + nodes map[string]*Upgrades +} + +func newRoundBed(t *testing.T, order ...string) *roundBed { + k := newKeeperBed(t) + k.now = time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC) // a Saturday + b := &roundBed{keeperBed: k, nodes: map[string]*Upgrades{}} + var updates UpdatesValue + k.updates.value("node-a", &updates) + for _, n := range []string{"node-a", "node-b", "node-c"} { + u := k.keeper(k.u.Machine) + u.Node, u.Config.UpgradeOrder, u.Config.RoundStartMinutes = n, order, 120 + if _, err := k.installed.Put(n, nodeAPackages); err != nil { + t.Fatal(err) + } + if _, err := k.updates.Put(n, updates); err != nil { + t.Fatal(err) + } + b.nodes[n] = u + } + return b +} + +func (b *roundBed) step(t *testing.T, node string) time.Time { + t.Helper() + next, err := b.nodes[node].RoundStep() + if err != nil { + t.Fatalf("%s: %v", node, err) + } + return next +} + +// end ends node's running transaction as unitState says and follows it. +func (b *roundBed) end(t *testing.T, node, unitState string) { + t.Helper() + b.unitState = unitState + if err := b.nodes[node].Follow(); err != nil { + t.Fatal(err) + } + b.now = b.now.Add(5 * time.Minute) +} + +func (b *roundBed) runsOf(node string) []Run { + keys, _ := b.runs.Keys() + var out []Run + for _, k := range keys { + if strings.HasPrefix(k, node+".") { + var r Run + b.runs.value(k, &r) + out = append(out, r) + } + } + return out +} + +func (b *roundBed) state(t *testing.T) map[string]NodeInRound { + t.Helper() + v, err := readRound(b.rounds, b.runs, "2026-W43") + if err != nil || v == nil { + t.Fatalf("round: %v", err) + } + out := map[string]NodeInRound{} + for _, s := range RoundState(v, b.now, 120) { + out[s.Node] = s + } + return out +} + +func TestTheRoundOpensSaturdayAtFourAndThePassedCheckGivesTheNextItsTurn(t *testing.T) { + if o := RoundOpening(time.Date(2026, 10, 22, 15, 0, 0, 0, time.UTC)); !o.Equal(time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)) { + t.Fatalf("the opening of the week of Thursday 22 October: %s", o) + } + b := newRoundBed(t, "node-a", "node-b", "node-c") + if next := b.step(t, "node-a"); !next.Equal(time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)) { + t.Errorf("before 04:00 the first node waits for 04:00: %s", next) + } + if keys, _ := b.rounds.Keys(); len(keys) != 0 { + t.Fatalf("a round before 04:00: %v", keys) + } + b.step(t, "node-b") + if keys, _ := b.rounds.Keys(); len(keys) != 0 { + t.Fatalf("only the first of the order opens the round: %v", keys) + } + + b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) + if next := b.step(t, "node-a"); !next.Equal(time.Date(2026, 10, 31, 4, 0, 0, 0, time.UTC)) { + t.Errorf("after opening, next Saturday: %s", next) + } + var round Round + if !b.rounds.value("2026-W43", &round) || round.OpenedBy != "node-a" || strings.Join(round.Order, " ") != "node-a node-b node-c" { + t.Fatalf("the round: %+v", round) + } + if r := b.runsOf("node-a"); len(r) != 1 || r[0].Round != "2026-W43" || r[0].From != FromRound || lastStep(r[0]) != StepStarted { + t.Fatalf("the first node starts its run in the round: %+v", r) + } + b.now = b.now.Add(time.Minute) + b.step(t, "node-a") + if w := b.rounds.writesTo("2026-W43"); len(w) != 2 || len(b.runsOf("node-a")) != 1 { + t.Errorf("the round is created once and the run started once: %v", w) + } + b.step(t, "node-b") + if len(b.runsOf("node-b")) != 0 || b.state(t)["node-b"].State != InRoundWaiting { + t.Fatalf("node-b waits while node-a runs: %+v", b.state(t)["node-b"]) + } + + b.end(t, "node-a", "LoadState=not-found\n") + if s := b.state(t)["node-a"]; s.State != InRoundPassed { + t.Fatalf("node-a: %+v", s) + } + b.step(t, "node-c") + if len(b.runsOf("node-c")) != 0 { + t.Fatal("node-c started before node-b") + } + b.step(t, "node-b") + if r := b.runsOf("node-b"); len(r) != 1 || r[0].Round != "2026-W43" { + t.Fatalf("node-b's turn after node-a's check passed: %+v", r) + } + + // node-b's transaction fails: the round stops at node-b, and node-c waits. + b.end(t, "node-b", "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n") + b.step(t, "node-c") + st := b.state(t) + if len(b.runsOf("node-c")) != 0 || st["node-b"].State != InRoundFailed || st["node-c"].State != InRoundWaiting || st["node-c"].WaitsFor != "node-b" { + t.Errorf("a failed check stops the round: %+v %+v", st["node-b"], st["node-c"]) + } +} + +func TestNoOrderNoRound(t *testing.T) { + b := newRoundBed(t) + b.now = time.Date(2026, 10, 24, 5, 0, 0, 0, time.UTC) + if next := b.step(t, "node-a"); !next.IsZero() { + t.Errorf("no wake without an order: %s", next) + } + if keys, _ := b.rounds.Keys(); len(keys) != 0 || len(b.runsOf("node-a")) != 0 { + t.Errorf("a round without an order: %v", keys) + } +} + +func TestANodeOffAtItsTurnRunsAtItsNextStartAndAStalledTurnIsSaid(t *testing.T) { + b := newRoundBed(t, "node-a", "node-b", "node-c") + b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) + b.step(t, "node-a") + b.end(t, "node-a", "LoadState=not-found\n") + passed := b.now.Add(-5 * time.Minute) + b.now = passed.Add(121 * time.Minute) // node-b is off + st := b.state(t) + if st["node-b"].State != InRoundStalled || st["node-b"].Since == nil || !st["node-b"].Since.Equal(passed) { + t.Errorf("node-b's turn came at %s and it has not started: %+v", passed, st["node-b"]) + } + if st["node-c"].WaitsFor != "node-b" || st["node-c"].State != InRoundWaiting { + t.Errorf("node-c names the node it waits for: %+v", st["node-c"]) + } + b.step(t, "node-b") // node-b starts again + if len(b.runsOf("node-b")) != 1 { + t.Error("a node off at its turn runs at its next start") + } +} + +// warrant is the operator channel's record of an ask answered with option. +func warrant(a asks.Ask, option string, level asks.Level, at time.Time) json.RawMessage { + w := asks.Warrant{Ask: a.ID, Asker: Module, Outcome: asks.OutcomeChosen, Option: option, Level: level, + By: &asks.Person{Who: asks.Operator, Kind: "made-up-channel", Identity: "1", Verified: "user id"}, At: at, AskDigest: a.Digest()} + raw, _ := json.Marshal(map[string]any{"state": "chosen", "warrant": w}) + return raw +} + +func TestOnlyTheOperatorsApprovePassesANodeAndAPassNeedsAReason(t *testing.T) { + b := newRoundBed(t, "node-a", "node-b", "node-c") + b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) + b.step(t, "node-a") + b.end(t, "node-a", "LoadState=not-found\n") + b.step(t, "node-b") + b.end(t, "node-b", "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n") + + var published []asks.Ask + var subjects []string + saved := seatPublish + seatPublish = func(subject string, body any, id string) error { + subjects, published = append(subjects, subject), append(published, body.(asks.Ask)) + return nil + } + defer func() { seatPublish = saved }() + if _, err := AskPass(b.rounds, b.runs, b.asks, "", "node-b", " ", b.now); err == nil || len(published) != 0 { + t.Fatalf("a pass without a reason: %v", err) + } + out, err := AskPass(b.rounds, b.runs, b.asks, "", "node-b", "its disk is being replaced", b.now) + if err != nil { + t.Fatal(err) + } + if len(published) != 1 || subjects[0] != "mesh.seat.operator-channel.accept.ask.pacman" || out["waiting"] != "node-c" { + t.Fatalf("the operator is asked on their channel: %v %v", subjects, out) + } + opAsk := published[0] + if err := opAsk.Check(b.now); err != nil || !strings.Contains(opAsk.Explanation, "its disk is being replaced") { + t.Errorf("the ask: %v %+v", err, opAsk) + } + keys, _ := b.asks.Keys() + if len(keys) != 1 || keys[0] != NodeKey("node-c", opAsk.ID) { + t.Fatalf("the ask for node-c's keeper: %v", keys) + } + var a Ask + b.asks.value(keys[0], &a) + raw, _ := json.Marshal(a) + if err := b.nodes["node-c"].TakeAsk(stdio.StateChange{State: StateAsks, Key: keys[0], Op: "put", Value: raw}); err != nil { + t.Fatal(err) + } + if st := b.state(t)["node-b"]; st.Pass == nil || st.Pass.Reason != "its disk is being replaced" || st.Pass.Answer != "" { + t.Fatalf("the pass waits in the round: %+v", st.Pass) + } + + record := map[string]json.RawMessage{} + b.nodes["node-c"].AskRecord = func(id string) (*stdio.StateEntry, error) { + if v, ok := record[id]; ok { + return &stdio.StateEntry{Key: Module + "." + id, Value: v}, nil + } + return nil, nil + } + if next := b.step(t, "node-c"); len(b.runsOf("node-c")) != 0 || !next.Equal(b.now.Add(passCheckEvery)) { + t.Fatalf("no answer yet: node-c waits and looks again in a minute (%s)", next) + } + // An answer to another ask, or to this one as it was not shown, passes nothing. + other := opAsk + other.Explanation = "something else" + record[opAsk.ID] = warrant(other, "pass", asks.Approve, b.now) + if _, err := b.nodes["node-c"].RoundStep(); err == nil || len(b.runsOf("node-c")) != 0 { + t.Fatalf("a warrant for another ask: %v", err) + } + record[opAsk.ID] = warrant(opAsk, "pass", asks.Approve, b.now) + b.step(t, "node-c") + if st := b.state(t)["node-b"]; st.State != InRoundPassed || st.Pass.Answer != PassApproved { + t.Errorf("the operator's Approve passes node-b: %+v", st) + } + if r := b.runsOf("node-c"); len(r) != 1 || r[0].Round != "2026-W43" { + t.Errorf("node-c's turn after the pass: %+v", r) + } +} + +func TestAPassTheOperatorKeepsWaitingPassesNothing(t *testing.T) { + b := newRoundBed(t, "node-a", "node-b") + b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) + b.step(t, "node-a") // node-a runs and never ends + saved := seatPublish + var opAsk asks.Ask + seatPublish = func(_ string, body any, _ string) error { opAsk = body.(asks.Ask); return nil } + defer func() { seatPublish = saved }() + if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-a", "it hangs", b.now); err != nil { + t.Fatal(err) + } + keys, _ := b.asks.Keys() + var a Ask + b.asks.value(keys[0], &a) + raw, _ := json.Marshal(a) + if err := b.nodes["node-b"].TakeAsk(stdio.StateChange{State: StateAsks, Key: keys[0], Op: "put", Value: raw}); err != nil { + t.Fatal(err) + } + b.nodes["node-b"].AskRecord = func(string) (*stdio.StateEntry, error) { + return &stdio.StateEntry{Value: warrant(opAsk, "keep", asks.Acknowledge, b.now)}, nil + } + b.step(t, "node-b") + if st := b.state(t); st["node-a"].Pass.Answer != PassDeclined || len(b.runsOf("node-b")) != 0 { + t.Errorf("kept waiting: %+v", st["node-a"]) + } + if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-b", "x", b.now); err == nil { + t.Error("the last of the order has no node waiting on it") + } + if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-z", "x", b.now); err == nil { + t.Error("a node not in the order") + } +} + +func TestPacmanRoundReadsTheRounds(t *testing.T) { + b := newRoundBed(t, "node-a", "node-b") + b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) + b.step(t, "node-a") + out, err := ListRounds(b.rounds, b.runs, "", b.now, 120, 4) + if err != nil { + t.Fatal(err) + } + listed := out["rounds"].([]map[string]any) + nodes := listed[0]["nodes"].([]NodeInRound) + if len(listed) != 1 || listed[0]["round"] != "2026-W43" || nodes[0].State != InRoundRunning || nodes[1].WaitsFor != "node-a" { + t.Errorf("%+v", listed) + } + if _, err := ListRounds(b.rounds, b.runs, "2026-W40", b.now, 120, 4); err == nil { + t.Error("a round that is not there") + } +} diff --git a/modules/pacman/cmd/pacman-tools/runs_test.go b/modules/pacman/cmd/pacman-tools/runs_test.go new file mode 100644 index 00000000..d339b695 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/runs_test.go @@ -0,0 +1,217 @@ +package main + +// Runs on the bus and the log read once (novox/hq ADR 0508 §1; acceptance 1841/AC1 for the writing of steps, +// 1841/AC2 for the log and pacman_upgrades). + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func capturedLog(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile("testdata/pacman-log-three-full-upgrades/pacman.log") + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// The captured log holds three full upgrades among other commands: one that ended before any transaction, one +// whose transaction completed, and a later one that upgraded one package. +func TestEveryFullUpgradeOfTheLogIsARunReadFromIt(t *testing.T) { + runs := PastUpgrades("node-a", capturedLog(t)) + if len(runs) != 3 { + t.Fatalf("%d runs, want the three full upgrades and none of the other commands: %+v", len(runs), runs) + } + for _, r := range runs { + if r.Node != "node-a" || r.Kind != RunUpgrade || r.From != FromLog || len(r.Steps) != 2 || r.Steps[0].Step != StepStarted { + t.Errorf("a run read from the log: %+v", r) + } + } + first, second, third := runs[0], runs[1], runs[2] + if first.Run != "20261004T130648Z" || first.Steps[1].Step != StepFinished || first.Steps[1].Why != noTransaction || + len(first.Steps[1].Changes.Changed) != 0 { + t.Errorf("an upgrade with no transaction says so: %+v", first) + } + end := second.Steps[1] + if second.Run != "20261004T130724Z" || end.Step != StepFinished || !end.At.Equal(time.Date(2026, 10, 4, 13, 22, 50, 0, time.UTC)) { + t.Errorf("the second upgrade finished when its transaction completed: %+v", second) + } + var names []string + for _, c := range end.Changes.Changed { + names = append(names, c.Name) + } + if strings.Join(names, " ") != "bash glibc iana-etc readline tzdata wireshark-qt xfsprogs xpad xterm zathura-pdf-mupdf" { + t.Errorf("what it changed, sorted: %v", names) + } + if c := end.Changes.Changed[0]; c.From != "5.3.15-1" || c.To != "5.3.20-1" { + t.Errorf("bash: %+v", c) + } + if len(end.Changes.Removed) != 0 { + t.Errorf("the removal run between them is no part of an upgrade: %+v", end.Changes.Removed) + } + if c := third.Steps[1].Changes.Changed; third.Run != "20261004T132817Z" || len(c) != 1 || c[0].Name != "dunst" { + t.Errorf("the third upgrade: %+v", third) + } +} + +func TestATransactionWithoutAnEndOrThatFailedIsAFailedRun(t *testing.T) { + log := "[2026-10-10T04:00:00+0000] [PACMAN] Running 'pacman -Syu --noconfirm'\n" + + "[2026-10-10T04:00:01+0000] [PACMAN] starting full system upgrade\n" + + "[2026-10-10T04:00:05+0000] [ALPM] transaction started\n" + + "[2026-10-10T04:00:06+0000] [ALPM] upgraded linux (7.2.8.arch1-2 -> 7.2.9.arch1-1)\n" + + "[2026-10-10T04:00:09+0000] [ALPM] transaction failed\n" + + "[2026-10-11T04:00:00+0000] [PACMAN] Running 'pacman -Syu --noconfirm'\n" + + "[2026-10-11T04:00:01+0000] [PACMAN] starting full system upgrade\n" + + "[2026-10-11T04:00:05+0000] [ALPM] transaction started\n" + runs := PastUpgrades("node-b", log) + if len(runs) != 2 { + t.Fatalf("%+v", runs) + } + if s := runs[0].Steps[1]; s.Step != StepFailed || s.Error != "transaction failed" || len(s.Changes.Changed) != 1 { + t.Errorf("a failed transaction: %+v", s) + } + if s := runs[1].Steps[1]; s.Step != StepFailed || !strings.Contains(s.Error, "no end") { + t.Errorf("a transaction the log holds no end of: %+v", s) + } +} + +func TestTheLogIsReadIntoRunsOnceAndASecondStartReadsNothing(t *testing.T) { + m := machine(nil, 1000) + reads := 0 + log := capturedLog(t) + m.ReadFile = func(path string) ([]byte, error) { + if path != PacmanLog { + t.Fatalf("read %s", path) + } + reads++ + return []byte(log), nil + } + runs := newBucket() + other := Run{Node: "node-b", Run: "20261001T040000Z", Kind: RunUpgrade, From: FromRound} + if _, err := runs.Create(NodeKey("node-b", other.Run), other); err != nil { + t.Fatal(err) + } + u := &Upgrades{Node: "node-a", Machine: m, Runs: runs} + n, err := u.ImportLog() + if err != nil || n != 3 || reads != 1 { + t.Fatalf("first start: %d runs, %d reads, %v", n, reads, err) + } + var r Run + if !runs.value("node-a.20261004T132817Z", &r) || r.From != FromLog { + t.Errorf("the run is kept at ., marked as read from the log: %+v", r) + } + before := len(runs.writes) + n, err = u.ImportLog() + if err != nil || n != 0 || reads != 1 || len(runs.writes) != before { + t.Errorf("a second start: %d runs, %d reads, %d writes, %v", n, reads, len(runs.writes)-before, err) + } +} + +// A run's key is created at its first step and then only updated from the revision its writer holds: a step +// is written once, and a run another writer changed meanwhile is refused, never overwritten. +func TestAStepIsWrittenOnceAndAnotherWritersChangeIsNeverOverwritten(t *testing.T) { + runs := newBucket() + u := &Upgrades{Node: "node-a", Runs: runs} + r := &openRun{Run: Run{Node: "node-a", Run: "20261024T040000Z", Kind: RunUpgrade, From: FromAsk}} + at := time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) + for _, s := range []string{StepRecorded, StepCheckedBefore} { + if err := u.step(r, Step{Step: s, At: at}); err != nil { + t.Fatal(err) + } + } + if w := strings.Join(runs.writes, ", "); w != "create node-a.20261024T040000Z, update node-a.20261024T040000Z" { + t.Errorf("writes: %s", w) + } + var kept Run + runs.value("node-a.20261024T040000Z", &kept) + if len(kept.Steps) != 2 || kept.Steps[1].Step != StepCheckedBefore { + t.Errorf("kept: %+v", kept) + } + if _, err := runs.Put("node-a.20261024T040000Z", kept); err != nil { + t.Fatal(err) + } + if err := u.step(r, Step{Step: StepStarted, At: at}); !errors.Is(err, stdio.ErrChanged) || len(r.Steps) != 2 { + t.Errorf("a step over another writer's change: %v, %d steps held", err, len(r.Steps)) + } +} + +func TestPacmanUpgradesAnswersAllOneNodeDaysAndOneRun(t *testing.T) { + runs := newBucket() + put := func(node, id string, steps ...Step) { + r := Run{Node: node, Run: id, Kind: RunUpgrade, From: FromRound, Round: "2026-W43", Steps: steps} + if _, err := runs.Create(NodeKey(node, id), r); err != nil { + t.Fatal(err) + } + } + at := time.Date(2026, 10, 24, 4, 30, 0, 0, time.UTC) + passed := &Check{Passed: true} + put("node-a", "20261024T040000Z", Step{Step: StepRecorded, At: at}, Step{Step: StepFinished, At: at}, Step{Step: StepCheckedAfter, At: at, Check: passed}) + put("node-b", "20261024T050000Z", Step{Step: StepRecorded, At: at}, Step{Step: StepStarted, At: at}) + put("node-b", "20261017T050000Z", Step{Step: StepRefused, At: at, Why: "installed is not current"}) + all, err := ListUpgrades(runs, "", "", "", "", 50) + if err != nil { + t.Fatal(err) + } + listed := all["runs"].([]RunSummary) + var order []string + for _, s := range listed { + order = append(order, s.Node+"/"+s.Run+"/"+s.Outcome+"/"+s.Checked) + } + if strings.Join(order, " ") != "node-b/20261024T050000Z/running/ node-a/20261024T040000Z/finished/passed node-b/20261017T050000Z/refused/" { + t.Fatalf("all runs, newest first: %v", order) + } + if listed[2].Error != "installed is not current" { + t.Errorf("a refused run says why: %+v", listed[2]) + } + one, _ := ListUpgrades(runs, "node-b", "", "", "", 50) + if one["count"] != 2 { + t.Errorf("one node: %v", one) + } + days, _ := ListUpgrades(runs, "", "2026-10-18", "2026-10-24", "", 50) + if days["count"] != 2 { + t.Errorf("between two days, the last one included: %v", days) + } + limited, _ := ListUpgrades(runs, "", "", "", "", 1) + if limited["count"] != 1 || limited["truncated"] != true { + t.Errorf("limited: %v", limited) + } + whole, err := ListUpgrades(runs, "node-a", "", "", "20261024T040000Z", 50) + if err != nil || len(whole["run"].(Run).Steps) != 3 { + t.Errorf("one run whole: %v %v", whole, err) + } + if _, err := ListUpgrades(runs, "node-a", "", "", "20261024T050000Z", 50); err == nil { + t.Error("a run the node does not have was answered") + } + if _, err := ListUpgrades(runs, "", "", "", "20261024T040000Z", 50); err == nil { + t.Error("a run without its node was answered") + } +} + +func TestTheUpgradesToolReadsTheRunsState(t *testing.T) { + runs := newBucket() + saved := upgradeState + upgradeState = func(name string) Bucket { + if name != StateRuns { + t.Fatalf("read %s", name) + } + return runs + } + defer func() { upgradeState = saved }() + for _, tool := range tools(machine(nil, 1000)) { + if tool.Name == "pacman_upgrades" { + out, err := tool.Run(map[string]any{"node": "node-a"}) + if err != nil || out.(map[string]any)["count"] != 0 { + t.Errorf("%v %v", out, err) + } + return + } + } + t.Fatal("no pacman_upgrades") +} diff --git a/modules/pacman/cmd/pacman-tools/shapes.go b/modules/pacman/cmd/pacman-tools/shapes.go new file mode 100644 index 00000000..3c364538 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/shapes.go @@ -0,0 +1,474 @@ +package main + +// The keeper's bus shapes (novox/hq ADR 0507, ADR 0508): the value of every key of the module's states and the +// body of every event its process emits, in one place, so the process that writes them and the verbs that read +// them agree by type. Each is a struct of named fields only: no map and no free-form value, so nothing but what +// is named here goes on the bus (ADR 0507 §1: package names, versions, unit and container names, file paths and +// exit statuses, never a process's command line or environment). A time in a value is the time of the thing it +// describes (a transaction, a step, an opening), never the time it was read: a key is written only when what it +// describes changed (ADR 0289 §1), and no field changes on a read alone (ADR 0289 §4). When a key last changed +// is the bus's to say (its revision), not a field's. +// +// A key is `` or `.`: a node name and an id are each one key token ([A-Za-z0-9_-]), so the +// dot only ever separates them. + +import ( + "fmt" + "time" +) + +// The module's states (manifest `state` and the keeper's `bus` `state`). +const ( + // StateInstalled holds each node's installed packages: key `` an InstalledValue, and key + // `.not-current` a NotCurrent while the node's packages are over the bus's cap (ADR 0507 §2). + StateInstalled = "installed" + // StateUpdates holds what a full upgrade would change: key `` an UpdatesValue (ADR 0507 §4). + StateUpdates = "updates" + // StateRuns holds every upgrade and rollback: key `.` a Run, kept (ADR 0508 §1). + StateRuns = "runs" + // StateRecords holds the package record taken before a run: key `.` a Record, deleted after + // the config value `record-days` (ADR 0508 §2). + StateRecords = "records" + // StateRounds holds the weekly rounds: key `` a Round, created once by the first node of the order, + // and key `.` that node's RoundNode (ADR 0508 §5, §6). + StateRounds = "rounds" + // StateAsks holds the acts asked of a node: key `.` an Ask, taken by that node's keeper alone + // (ADR 0508 §8). + StateAsks = "asks" +) + +// States are the module's states, in the manifest's order. +var States = []string{StateInstalled, StateUpdates, StateRuns, StateRecords, StateRounds, StateAsks} + +// The events the keeper emits (the keeper's `bus` `emits`, and the manifest's `emits`). +const ( + EventPackageChanged = "package.changed" // PackageChanged (ADR 0507 §3) + EventRecorded = "upgrade.recorded" // UpgradeRecorded (ADR 0508 §1, §2) + EventKernelHeld = "upgrade.kernel-held" // KernelHeld (ADR 0508 §3) + EventStarted = "upgrade.started" // UpgradeStarted + EventFinished = "upgrade.finished" // UpgradeFinished + EventFailed = "upgrade.failed" // UpgradeFailed + EventChecked = "upgrade.checked" // UpgradeChecked (ADR 0508 §4) + EventRollbackStarted = "rollback.started" // RollbackStarted (ADR 0508 §7) + EventRollbackFinished = "rollback.finished" // RollbackFinished +) + +// Events are the events the keeper emits, each with the type of its body. +var Events = []struct { + Name string + Body any +}{ + {EventPackageChanged, PackageChanged{}}, + {EventRecorded, UpgradeRecorded{}}, + {EventKernelHeld, KernelHeld{}}, + {EventStarted, UpgradeStarted{}}, + {EventFinished, UpgradeFinished{}}, + {EventFailed, UpgradeFailed{}}, + {EventChecked, UpgradeChecked{}}, + {EventRollbackStarted, RollbackStarted{}}, + {EventRollbackFinished, RollbackFinished{}}, +} + +// Key names. + +// NotCurrentKey is the key in `installed` that marks a node's packages as not current. +func NotCurrentKey(node string) string { return node + ".not-current" } + +// NodeKey is a key `.`: a run in `runs` or `records`, an ask in `asks`. +func NodeKey(node, id string) string { return node + "." + id } + +// RoundNodeKey is a node's key in a round: `.` in `rounds`. +func RoundNodeKey(round, node string) string { return round + "." + node } + +// RunID names a run by the moment it began, in UTC: `20261024T040012Z`. A run read from the package manager's +// log is named by the log's time of its start the same way. +func RunID(began time.Time) string { return began.UTC().Format("20060102T150405Z") } + +// RoundID names the round of the ISO week that holds the given moment, in its own time zone: `2026-W43`. The +// round opens on Saturday 04:00 local time, so the local date decides the week. +func RoundID(at time.Time) string { + year, week := at.ISOWeek() + return fmt.Sprintf("%04d-W%02d", year, week) +} + +// installed + +// InstalledValue is `installed.`: every installed package, sorted by name, and the time of the transaction +// that left the database so (ADR 0507 §2). Written compactly: one short entry per package. +type InstalledValue struct { + At time.Time `json:"at"` + Packages []InstalledPackage `json:"packages"` +} + +// InstalledPackage is one installed package: its version, whether it was installed explicitly (else as a +// dependency), and its repository, or RepoForeign when no repository holds it (ADR 0507 §7). +type InstalledPackage struct { + Name string `json:"name"` + Version string `json:"version"` + Explicit bool `json:"explicit"` + Repo string `json:"repo"` +} + +// RepoForeign marks a package from outside the repositories. +const RepoForeign = "foreign" + +// NotCurrent is `installed..not-current`: the node's packages came to more than the bus's cap on a value, +// so `installed.` keeps the last value written. Written once, when it first happens, and deleted at the +// next successful write of `installed.`. +type NotCurrent struct { + Bytes int `json:"bytes"` + Since time.Time `json:"since"` +} + +// updates + +// UpdatesValue is `updates.`: every package a full upgrade would change, sorted by name, and whether the +// node reached its mirrors (ADR 0507 §4). A failed read writes Reachable false and keeps the last list, never an +// empty list in its place. +type UpdatesValue struct { + Reachable bool `json:"reachable"` + Packages []VersionChange `json:"packages"` +} + +// VersionChange is one package going from one version to another. +type VersionChange struct { + Name string `json:"name"` + From string `json:"from"` + To string `json:"to"` +} + +// PackageVersion is one package at one version. +type PackageVersion struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// runs + +// The kinds of run. +const ( + RunUpgrade = "upgrade" + RunRollback = "rollback" +) + +// What started a run. +const ( + FromRound = "round" // its turn in the week's round + FromAsk = "ask" // an ask: pacman_upgrade, or pacman_rollback with apply + FromLog = "log" // read from the package manager's log at the keeper's first start (ADR 0508 §1) +) + +// The steps of a run, in the order they are written. A run that does not start ends at StepRefused; a +// transaction ends at StepFinished or StepFailed, and the after-upgrade check follows either. +const ( + StepRecorded = "recorded" // the record is written and read back (upgrade only) + StepCheckedBefore = "checked-before" // the DKMS gate ran; Held names what it held + StepRefused = "refused" // the run did not start; Why says why + StepStarted = "started" // the transaction's unit started + StepFinished = "finished" // the transaction exited 0 + StepFailed = "failed" // the transaction failed + StepCheckedAfter = "checked-after" // the after-upgrade check +) + +// Run is `runs..`: one upgrade or rollback of a node, written at each step and at no other time +// (ADR 0508 §1). Round names the round it ran in; Ask the ask that started it (the keeper deletes the ask once +// the run names it); Of, for a rollback, the run whose record it returns to. +type Run struct { + Node string `json:"node"` + Run string `json:"run"` + Kind string `json:"kind"` + From string `json:"from"` + Round string `json:"round,omitempty"` + Ask string `json:"ask,omitempty"` + Of string `json:"of,omitempty"` + Steps []Step `json:"steps"` +} + +// Step is one step of a run with its time and outcome; only the fields of its kind are set. +type Step struct { + Step string `json:"step"` + At time.Time `json:"at"` + // StepRefused: why the run did not start. + Why string `json:"why,omitempty"` + // StepCheckedBefore: the kernels held from this run. + Held []HeldKernel `json:"held,omitempty"` + // StepStarted: the transaction's unit, the packages left out of it (held kernels and their headers), and + // for a rollback its plan. + Unit string `json:"unit,omitempty"` + Ignored []string `json:"ignored,omitempty"` + Plan []RollbackItem `json:"plan,omitempty"` + // StepFinished and StepFailed: the exit status, the last error line, and what the transaction changed, + // read from its lines in the package manager's log. + Exit *int `json:"exit,omitempty"` + Error string `json:"error,omitempty"` + Changes *Changes `json:"changes,omitempty"` + // StepCheckedAfter: the after-upgrade check. + Check *Check `json:"check,omitempty"` +} + +// HeldKernel is a kernel package left out of a run because an installed DKMS module's own bound excludes its +// new version (ADR 0508 §3): `Linux-Maximum` in the module's META, or `BUILD_EXCLUSIVE_KERNEL` in its dkms.conf. +type HeldKernel struct { + Package string `json:"package"` + Version string `json:"version"` + Module string `json:"module"` + Bound string `json:"bound"` +} + +// Changes is what a transaction changed, each list sorted by name. +type Changes struct { + Added []PackageVersion `json:"added"` + Removed []PackageVersion `json:"removed"` + Changed []VersionChange `json:"changed"` +} + +// Check is the after-upgrade check (ADR 0508 §4). Passed when the transaction succeeded, no unit newly failed +// and every DKMS module is built for every installed kernel; the rest is said and does not stop the round. +type Check struct { + Passed bool `json:"passed"` + // The .pacnew and .pacsave files the transaction left, by path. + Pacnew []string `json:"pacnew"` + Pacsave []string `json:"pacsave"` + // The distribution's news since the node's previous upgrade. + News []NewsTitle `json:"news"` + // Why the news could not be read, when it could not: no news is then not said to be none. + NewsError string `json:"news_error,omitempty"` + // The units and containers whose processes still map a file the upgrade replaced. + Mapped []Mapped `json:"mapped"` + // Why the mapped files could not be read, when they could not. + MappedError string `json:"mapped_error,omitempty"` + // The units failed now that were not failed in the record. + NewlyFailed []string `json:"newly_failed"` + // Whether a restart is needed: the running kernel is not the installed version of its package. + RestartNeeded bool `json:"restart_needed"` + RunningKernel string `json:"running_kernel"` + InstalledKernel string `json:"installed_kernel"` + // The DKMS modules not built for an installed kernel. + Unbuilt []DKMSBuild `json:"unbuilt"` + // Why the DKMS builds could not be read, when they could not; the check then does not pass. + DKMSError string `json:"dkms_error,omitempty"` +} + +// NewsTitle is one item of the distribution's news, without its text. +type NewsTitle struct { + Title string `json:"title"` + Link string `json:"link"` + Published string `json:"published"` +} + +// Mapped is a process still mapping a replaced file, named by its unit or its container and its executable's +// path; never its command line or environment. +type Mapped struct { + Unit string `json:"unit,omitempty"` + Container string `json:"container,omitempty"` + Executable string `json:"executable"` +} + +// DKMSBuild is one DKMS module at one version for one kernel. +type DKMSBuild struct { + Module string `json:"module"` + Version string `json:"version"` + Kernel string `json:"kernel"` +} + +// RollbackItem is one package a rollback brings back (ADR 0508 §7): from its version now to the record's, and +// where that version comes from. +type RollbackItem struct { + Name string `json:"name"` + From string `json:"from"` + To string `json:"to"` + Source string `json:"source"` + Location string `json:"location"` +} + +// Where a rollback takes a package's version from, in the order it is looked for. +const ( + SourceCache = "cache" // the node's package cache: a file path + SourceArchive = "archive" // the Arch Linux Archive's copy of that exact version: a URL + SourceRepository = "repository" // the archive's repository as it stood on the record's date: a URL +) + +// records + +// Record is `records..`: the node's packages taken before a run and read back (ADR 0508 §2): a copy of +// `installed.`, the running kernel (/proc/sys/kernel/osrelease), the units failed at that moment, and the +// installed DKMS modules. +type Record struct { + Node string `json:"node"` + Run string `json:"run"` + At time.Time `json:"at"` + Installed InstalledValue `json:"installed"` + Kernel string `json:"kernel"` + FailedUnits []string `json:"failed_units"` + DKMS []DKMSModule `json:"dkms"` +} + +// DKMSModule is one installed DKMS module and the kernels it is built for. +type DKMSModule struct { + Module string `json:"module"` + Version string `json:"version"` + Kernels []string `json:"kernels"` +} + +// rounds + +// Round is `rounds.`: the week's round, created once (a create, so one round per week) by the first node +// of the order when it opens it, with the order as it stood then (ADR 0508 §5). +type Round struct { + Round string `json:"round"` + Opened time.Time `json:"opened"` + OpenedBy string `json:"opened_by"` + Order []string `json:"order"` +} + +// RoundNode is `rounds..`: the node's place in the round. Run is the run it started in its turn, +// written by the node itself; Pass, the operator's pass of a node that stopped the round, written by the keeper +// of the node waiting on it (ADR 0508 §6). Both write through a compare-and-set. +type RoundNode struct { + Round string `json:"round"` + Node string `json:"node"` + Run string `json:"run,omitempty"` + Pass *Pass `json:"pass,omitempty"` +} + +// Pass is a pass of a node asked of the operator through the operator's channel (ADR 0259): the reason, the +// channel's ask, and its answer once given (Answer empty while it waits). +type Pass struct { + Reason string `json:"reason"` + Ask string `json:"ask"` + Asked time.Time `json:"asked"` + Answer string `json:"answer,omitempty"` + Answered *time.Time `json:"answered,omitempty"` +} + +// The operator's answers to a pass. +const ( + PassApproved = "approved" + PassDeclined = "declined" +) + +// asks + +// The acts an ask asks. +const ( + ActUpgrade = "upgrade" // pacman_upgrade + ActRollback = "rollback" // pacman_rollback with apply + ActPass = "pass" // pacman_round with pass +) + +// Ask is `asks..`: an act asked of a node, taken by that node's keeper alone, which answers in `runs` +// (an upgrade, a rollback) or in `rounds` (a pass) and then deletes the ask (ADR 0508 §8). A rollback names the +// run whose record it returns to and whether the packages the upgrade added are removed; a pass names the +// round, the node passed and the reason, and is asked of the node waiting on it. +type Ask struct { + ID string `json:"id"` + Node string `json:"node"` + Act string `json:"act"` + At time.Time `json:"at"` + Of string `json:"of,omitempty"` + RemoveAdded bool `json:"remove_added,omitempty"` + Round string `json:"round,omitempty"` + Pass string `json:"pass,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// events + +// PackageChanged is `package.changed`: a write of `installed.` that changed it (ADR 0507 §3). +type PackageChanged struct { + Node string `json:"node"` + At time.Time `json:"at"` + Added []PackageVersion `json:"added"` + Removed []PackageVersion `json:"removed"` + Changed []VersionChange `json:"changed"` +} + +// UpgradeRecorded is `upgrade.recorded`: the run's record is written and read back. +type UpgradeRecorded struct { + Node string `json:"node"` + Run string `json:"run"` + Packages int `json:"packages"` + Kernel string `json:"kernel"` +} + +// KernelHeld is `upgrade.kernel-held`: kernels left out of a run, each with the module and bound that hold it. +type KernelHeld struct { + Node string `json:"node"` + Run string `json:"run"` + Held []HeldKernel `json:"held"` +} + +// UpgradeStarted is `upgrade.started`: a run's transaction started. +type UpgradeStarted struct { + Node string `json:"node"` + Run string `json:"run"` + From string `json:"from"` + Round string `json:"round,omitempty"` + Unit string `json:"unit"` + Ignored []string `json:"ignored"` +} + +// UpgradeFinished is `upgrade.finished`: a run's transaction exited 0, with what it changed. +type UpgradeFinished struct { + Node string `json:"node"` + Run string `json:"run"` + Changes Changes `json:"changes"` +} + +// UpgradeFailed is `upgrade.failed`: a run that did not start (Step StepRefused, with why in Error) or whose +// transaction failed (Step StepFailed, with its exit status and last error line). +type UpgradeFailed struct { + Node string `json:"node"` + Run string `json:"run"` + Step string `json:"step"` + Exit *int `json:"exit,omitempty"` + Error string `json:"error"` +} + +// UpgradeChecked is `upgrade.checked`: the after-upgrade check of a run, an upgrade's or a rollback's; the +// check whole is in the run. +type UpgradeChecked struct { + Node string `json:"node"` + Run string `json:"run"` + Kind string `json:"kind"` + Passed bool `json:"passed"` +} + +// RollbackStarted is `rollback.started`: a rollback's transaction started, returning to the record of run Of. +type RollbackStarted struct { + Node string `json:"node"` + Run string `json:"run"` + Of string `json:"of"` + Unit string `json:"unit"` +} + +// RollbackFinished is `rollback.finished`: a rollback's transaction ended, with its exit status and, when it +// failed, its last error line. +type RollbackFinished struct { + Node string `json:"node"` + Run string `json:"run"` + Of string `json:"of"` + Exit int `json:"exit"` + Error string `json:"error,omitempty"` +} + +// The keeper's config file + +// The keeper's environment, named by the manifest's process resource. +const ( + EnvBroker = "PACMAN_KEEPER_BROKER" // the keeper's bus account file + EnvConfig = "PACMAN_KEEPER_CONFIG" // the keeper's config file, a KeeperConfig +) + +// KeeperConfig is the keeper's config file: the module's config values the mesh merges into it (ADR 0348 §5). +// The process reads it at its start; the mesh restarts the process when it changes (the manifest's +// `restart-on`). +type KeeperConfig struct { + // UpgradeOrder is the nodes in the order they upgrade; the first opens each week's round. Empty: no round. + UpgradeOrder []string `json:"upgrade-order"` + // RecordDays is how long a record is kept, in days. + RecordDays int `json:"record-days"` + // RoundStartMinutes is how long a node whose turn came may wait before starting, in minutes, before the + // round is said to stand still. + RoundStartMinutes int `json:"round-start-minutes"` +} diff --git a/modules/pacman/cmd/pacman-tools/stale_run_test.go b/modules/pacman/cmd/pacman-tools/stale_run_test.go new file mode 100644 index 00000000..0c9d9e7d --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/stale_run_test.go @@ -0,0 +1,115 @@ +package main + +// A run read from pacman's log, or a run whose transaction ended, never holds a new run back (issue 1841, the +// refusal of 2026-10-22 22:00 on the control-node: "run 20220123T194900Z is still going (its last step is finished): one +// run at a time"); and pacman_upgrades reads one run whole at an address that names the machine. + +import ( + "strings" + "testing" + "time" +) + +// installLogHead is the first twelve lines of the control-node's /var/log/pacman.log, captured 2026-10-22 22:10 +// with node-reads.read: the install's `pacman -Syu` with nothing to do, which became run 20220123T194900Z, its +// last step finished at the moment it began (pacman_upgrades there, 2026-10-22: from log, outcome finished, +// began 2022-01-23T19:49:00Z, ended 2022-01-23T20:49:00+01:00). +const installLogHead = "[2022-01-23T20:49:00+0100] [PACMAN] Running 'pacman -Syu'\n" + + "[2022-01-23T20:49:00+0100] [PACMAN] synchronizing package lists\n" + + "[2022-01-23T20:49:00+0100] [PACMAN] starting full system upgrade\n" + + "[2022-01-23T20:50:45+0100] [PACMAN] Running 'pacman -S nano'\n" + + "[2022-01-23T20:50:49+0100] [ALPM] transaction started\n" + + "[2022-01-23T20:50:50+0100] [ALPM] installed nano (6.0-1)\n" + + "[2022-01-23T20:50:50+0100] [ALPM] transaction completed\n" + + "[2022-01-23T20:50:50+0100] [ALPM] running '30-systemd-update.hook'...\n" + + "[2022-01-23T20:55:31+0100] [PACMAN] Running 'pacman -S docker'\n" + + "[2022-01-23T20:55:36+0100] [ALPM] transaction started\n" + + "[2022-01-23T20:55:36+0100] [ALPM] installed bridge-utils (1.7.1-1)\n" + + "[2022-01-23T20:55:36+0100] [ALPM] installed libtool (2.4.6+42+gb88cebd5-16)\n" + +// 1841/AC20: given the run read from the control-node's log, when an upgrade is asked, then it starts. +func TestARunReadFromTheLogIsHistoryAndNeverHoldsAnAsk(t *testing.T) { + k := newKeeperBed(t) + past := PastUpgrades("node-a", installLogHead) + if len(past) != 1 || past[0].Run != "20220123T194900Z" || steps(past[0]) != "started finished" || past[0].From != FromLog { + t.Fatalf("the captured log reads as the record the control-node holds: %+v", past) + } + if _, err := k.runs.Create(NodeKey("node-a", past[0].Run), past[0]); err != nil { + t.Fatal(err) + } + if _, err := k.u.StartUpgrade(FromAsk, "", "a1"); err != nil { + t.Fatal(err) + } + var r Run + k.runs.value("node-a.20261004T120000Z", &r) + if lastStep(r) == StepRefused || len(k.root.started) != 1 { + t.Errorf("a run read from the log held the ask back: %s %+v", steps(r), r.Steps) + } +} + +// 1841/AC21: given a run the mesh ran whose last step is finished, failed or refused (no after-upgrade check +// yet, no end time of the run itself), when another run would start, then the earlier one does not hold it; +// a run whose transaction may still go on (no step, recorded, checked before, started) does. +func TestARunWhoseTransactionEndedNeverHoldsTheNext(t *testing.T) { + at := time.Date(2026, 10, 22, 20, 0, 0, 0, time.UTC) + for last, holds := range map[string]bool{ + StepFinished: false, StepFailed: false, StepRefused: false, StepCheckedAfter: false, + "": true, StepRecorded: true, StepCheckedBefore: true, StepStarted: true, + } { + r := Run{Node: "node-a", Run: "20261022T200000Z", Kind: RunUpgrade, From: FromAsk} + if last != "" { + r.Steps = []Step{{Step: last, At: at}} + } + if got := holdsTheDatabase(r); got != holds { + t.Errorf("last step %q: holds %v, want %v", last, got, holds) + } + r.From = FromLog + if holdsTheDatabase(r) { + t.Errorf("a run read from the log, last step %q, holds the next", last) + } + } + + k := newKeeperBed(t) + done := Run{Node: "node-a", Run: "20261004T110000Z", Kind: RunUpgrade, From: FromAsk, + Steps: []Step{{Step: StepStarted, At: k.now}, {Step: StepFinished, At: k.now}}} + if _, err := k.runs.Create(NodeKey("node-a", done.Run), done); err != nil { + t.Fatal(err) + } + if _, err := k.u.StartUpgrade(FromAsk, "", "a1"); err != nil { + t.Fatal(err) + } + var r Run + k.runs.value("node-a.20261004T120000Z", &r) + if lastStep(r) == StepRefused { + t.Errorf("a finished run held the next: %+v", r.Steps) + } +} + +// 1841/AC22: given an address that names the machine (the console takes `node` out of the arguments), when +// pacman_upgrades is asked for one run, then that machine's run is answered whole. +func TestPacmanUpgradesReadsOneRunOfThisNodeWithoutANode(t *testing.T) { + runs := newBucket() + past := PastUpgrades("node-a", installLogHead)[0] + if _, err := runs.Create(NodeKey("node-a", past.Run), past); err != nil { + t.Fatal(err) + } + savedState, savedNode := upgradeState, thisNode + upgradeState = func(string) Bucket { return runs } + thisNode = func() string { return "node-a" } + defer func() { upgradeState, thisNode = savedState, savedNode }() + for _, tool := range tools(machine(nil, 1000)) { + if tool.Name != "pacman_upgrades" { + continue + } + out, err := tool.Run(map[string]any{"run": past.Run}) + if err != nil { + t.Fatalf("one run without a node: %v", err) + } + whole := out.(map[string]any)["run"].(Run) + if whole.Node != "node-a" || steps(whole) != "started finished" || !strings.Contains(whole.Steps[1].Why, "no transaction") { + t.Errorf("one run whole: %+v", whole) + } + return + } + t.Fatal("no pacman_upgrades") +} diff --git a/modules/pacman/cmd/pacman-tools/testdata/dkms-status-installed-and-added/status b/modules/pacman/cmd/pacman-tools/testdata/dkms-status-installed-and-added/status new file mode 100644 index 00000000..d19fd59a --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/testdata/dkms-status-installed-and-added/status @@ -0,0 +1,4 @@ +openrazer-driver/3.12.4, 7.2.8-arch1-2, x86_64: installed +vboxhost/7.2.20_OSE, 7.2.8-arch1-2, x86_64: installed +xone/0.5.8, 7.2.8-arch1-2, x86_64: installed +xpad/0.4: added diff --git a/modules/pacman/cmd/pacman-tools/testdata/dkms-zfs-linux-maximum-7.2/META b/modules/pacman/cmd/pacman-tools/testdata/dkms-zfs-linux-maximum-7.2/META new file mode 100644 index 00000000..8133bf1e --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/testdata/dkms-zfs-linux-maximum-7.2/META @@ -0,0 +1,10 @@ +Meta: 1 +Name: zfs +Branch: 1.0 +Version: 2.4.4 +Release: 1 +Release-Tags: relext +License: CDDL +Author: OpenZFS +Linux-Maximum: 7.2 +Linux-Minimum: 4.18 diff --git a/modules/pacman/cmd/pacman-tools/testdata/dkms-zfs-linux-maximum-7.2/dkms.conf b/modules/pacman/cmd/pacman-tools/testdata/dkms-zfs-linux-maximum-7.2/dkms.conf new file mode 100644 index 00000000..50136d8a --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/testdata/dkms-zfs-linux-maximum-7.2/dkms.conf @@ -0,0 +1,56 @@ +PACKAGE_NAME="zfs" +PACKAGE_VERSION="2.4.4" +PACKAGE_CONFIG="/etc/sysconfig/zfs" +NO_WEAK_MODULES="yes" +PRE_BUILD="configure + --disable-dependency-tracking + --prefix=/usr + --with-config=kernel + --with-linux="${kernel_source_dir}" + --with-linux-obj="${kernel_source_dir}" + $( + [[ -n \"${ICP_ROOT}\" ]] && \ + { + echo --with-qat=\"${ICP_ROOT}\" + } + ) + $( + [[ -r ${PACKAGE_CONFIG} ]] \ + && source ${PACKAGE_CONFIG} \ + && shopt -q -s extglob \ + && \ + { + if [[ ${ZFS_DKMS_ENABLE_DEBUG,,} == @(y|yes) ]] + then + echo --enable-debug + fi + if [[ ${ZFS_DKMS_ENABLE_DEBUGINFO,,} == @(y|yes) ]] + then + echo --enable-debuginfo + fi + } + ) +" +POST_BUILD="scripts/dkms.postbuild + -n ${PACKAGE_NAME} + -v ${PACKAGE_VERSION} + -a ${arch} + -k ${kernelver} + -t ${dkms_tree} +" +AUTOINSTALL="yes" +MAKE[0]="make -C module/" +STRIP[0]="$( + [[ -r ${PACKAGE_CONFIG} ]] \ + && source ${PACKAGE_CONFIG} \ + && shopt -q -s extglob \ + && [[ ${ZFS_DKMS_DISABLE_STRIP,,} == @(y|yes) ]] \ + && echo -n no +)" +STRIP[1]="${STRIP[0]}" +BUILT_MODULE_NAME[0]="zfs" +BUILT_MODULE_LOCATION[0]="module/" +DEST_MODULE_LOCATION[0]="/extra" +BUILT_MODULE_NAME[1]="spl" +BUILT_MODULE_LOCATION[1]="module/" +DEST_MODULE_LOCATION[1]="/extra" diff --git a/modules/pacman/cmd/pacman-tools/testdata/pacman-log-three-full-upgrades/pacman.log b/modules/pacman/cmd/pacman-tools/testdata/pacman-log-three-full-upgrades/pacman.log new file mode 100644 index 00000000..b35df8bd --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/testdata/pacman-log-three-full-upgrades/pacman.log @@ -0,0 +1,63 @@ +[2026-10-04T14:52:51+0200] [ALPM] installed logrotate (3.22.0-1) +[2026-10-04T14:52:51+0200] [ALPM] transaction completed +[2026-10-04T14:52:51+0200] [ALPM] running '30-systemd-daemon-reload-system.hook'... +[2026-10-04T14:54:22+0200] [ALPM] running '35-systemd-update.hook'... +[2026-10-04T15:06:13+0200] [PACMAN] Running 'pacman -Syu --noconfirm' +[2026-10-04T15:06:13+0200] [PACMAN] synchronizing package lists +[2026-10-04T15:06:48+0200] [PACMAN] starting full system upgrade +[2026-10-04T15:07:23+0200] [PACMAN] Running 'pacman -Rns --noconfirm lib32-libpcap' +[2026-10-04T15:07:23+0200] [ALPM] transaction started +[2026-10-04T15:07:24+0200] [ALPM] removed lib32-libpcap (1.10.6-1) +[2026-10-04T15:07:24+0200] [ALPM] removed lib32-libnl (3.12.0-1) +[2026-10-04T15:07:24+0200] [ALPM] transaction completed +[2026-10-04T15:07:24+0200] [ALPM] running '35-systemd-update.hook'... +[2026-10-04T15:07:24+0200] [PACMAN] Running 'pacman -Syu --noconfirm' +[2026-10-04T15:07:24+0200] [PACMAN] synchronizing package lists +[2026-10-04T15:07:24+0200] [PACMAN] starting full system upgrade +[2026-10-04T15:16:51+0200] [ALPM] running '60-mkinitcpio-remove.hook'... +[2026-10-04T15:16:52+0200] [ALPM] running '71-dkms-remove.hook'... +[2026-10-04T15:16:52+0200] [ALPM-SCRIPTLET] ==> dkms remove --no-depmod xone/0.5.8 -k 7.1.9-arch1-2 +[2026-10-04T15:16:52+0200] [ALPM-SCRIPTLET] ==> depmod 7.1.9-arch1-2 +[2026-10-04T15:16:55+0200] [ALPM] running 'ghc-unregister.hook'... +[2026-10-04T15:16:57+0200] [ALPM] running 'texinfo-remove.hook'... +[2026-10-04T15:16:58+0200] [ALPM] transaction started +[2026-10-04T15:16:58+0200] [ALPM] upgraded tzdata (2026c-1 -> 2026e-1) +[2026-10-04T15:16:58+0200] [ALPM] upgraded iana-etc (20260530-1 -> 20260617-1) +[2026-10-04T15:16:58+0200] [ALPM] upgraded glibc (2.44+r24+g16be1518495f-1 -> 2.44+r50+g1848099f063e-1) +[2026-10-04T15:16:58+0200] [ALPM] upgraded readline (8.3.003-1 -> 8.3.6-1) +[2026-10-04T15:16:58+0200] [ALPM] upgraded bash (5.3.15-1 -> 5.3.20-1) +[2026-10-04T15:22:46+0200] [ALPM] upgraded wireshark-qt (4.7.2-1 -> 4.7.3-1) +[2026-10-04T15:22:46+0200] [ALPM] upgraded xfsprogs (7.1.1-1 -> 7.2.0-1) +[2026-10-04T15:22:47+0200] [ALPM] upgraded xpad (5.8.0-1 -> 5.9.0-1) +[2026-10-04T15:22:47+0200] [ALPM] upgraded xterm (410-1 -> 411-1) +[2026-10-04T15:22:47+0200] [ALPM] upgraded zathura-pdf-mupdf (2026.07.18-1 -> 2026.07.18-4) +[2026-10-04T15:22:50+0200] [ALPM] transaction completed +[2026-10-04T15:22:52+0200] [ALPM] running '10-glibc-locale-gen.hook'... +[2026-10-04T15:22:52+0200] [ALPM-SCRIPTLET] Generating locales... +[2026-10-04T15:22:53+0200] [ALPM-SCRIPTLET] en_US.UTF-8... done +[2026-10-04T15:22:53+0200] [ALPM-SCRIPTLET] Generation complete. +[2026-10-04T15:22:53+0200] [ALPM] running '11-glibc-ldconfig.hook'... +[2026-10-04T15:24:49+0200] [ALPM] running 'texinfo-install.hook'... +[2026-10-04T15:24:51+0200] [ALPM] running 'update-desktop-database.hook'... +[2026-10-04T15:24:51+0200] [ALPM] running 'update-vlc-plugin-cache.hook'... +[2026-10-04T15:24:52+0200] [ALPM] running 'xorg-mkfontscale.hook'... +[2026-10-04T15:28:15+0200] [PACMAN] Running 'pacman -Syu' +[2026-10-04T15:28:15+0200] [PACMAN] synchronizing package lists +[2026-10-04T15:28:17+0200] [PACMAN] starting full system upgrade +[2026-10-04T15:30:26+0200] [ALPM] transaction started +[2026-10-04T15:30:26+0200] [ALPM] upgraded dunst (1.13.2-2 -> 1.13.2-3) +[2026-10-04T15:30:26+0200] [ALPM] transaction completed +[2026-10-04T15:30:26+0200] [ALPM] running '30-systemd-daemon-reload-user.hook'... +[2026-10-04T15:30:26+0200] [ALPM-SCRIPTLET] Failed to connect to system scope bus via local transport: Connection refused +[2026-10-04T15:30:26+0200] [ALPM] running '35-systemd-update.hook'... +[2026-10-04T15:30:26+0200] [ALPM] running 'i3status-rust.hook'... +[2026-10-04T16:00:43+0200] [PACMAN] Running 'pacman -R --noconfirm ntp' +[2026-10-04T16:00:43+0200] [ALPM] transaction started +[2026-10-04T16:00:45+0200] [ALPM] removed ntp (4.2.8.p18-6) +[2026-10-04T16:00:45+0200] [ALPM] transaction completed +[2026-10-04T16:00:45+0200] [ALPM] running '30-systemd-daemon-reload-system.hook'... +[2026-10-04T16:00:45+0200] [ALPM] running '35-systemd-update.hook'... +[2026-10-05T10:04:50+0200] [PACMAN] Running 'pacman -S --noconfirm --ask 4 lemurs' +[2026-10-05T10:04:51+0200] [ALPM] transaction started +[2026-10-05T10:04:51+0200] [ALPM] removed lemurs-git (0.4.0.r17.g7151336-2) +[2026-10-05T10:04:51+0200] [ALPM] installed lemurs (0.4.0-1) diff --git a/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/checkupdates b/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/checkupdates new file mode 100644 index 00000000..40c952dc --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/checkupdates @@ -0,0 +1,4 @@ +glibc 2.42-1 -> 2.42-2 +zsh 5.9-1 -> 5.9-2 PASSWORD=hunter2 +env FOO=bar -> /bin/sh +:: Synchronizing package databases... diff --git a/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/pacman-Q b/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/pacman-Q new file mode 100644 index 00000000..a8c96383 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/pacman-Q @@ -0,0 +1,9 @@ +glibc 2.42-1 +sudo pacman -Syu --noconfirm +yay 12.0-1 +LD_PRELOAD=/tmp/hook.so 1.0-1 +zsh 5.9-1 +bash 5.2-1 HOME=/root +evil 1.0;rm-rf +PATH=/usr/bin:/bin +warning: could not get file information for etc/secret diff --git a/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/pacman-Qeq b/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/pacman-Qeq new file mode 100644 index 00000000..815d6570 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/pacman-Qeq @@ -0,0 +1,3 @@ +yay +zsh +TOKEN=abc diff --git a/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/pacman-Sl b/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/pacman-Sl new file mode 100644 index 00000000..269f745e --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/testdata/pacman-output-with-command-lines-and-environment/pacman-Sl @@ -0,0 +1,3 @@ +core glibc 2.42-1 [installed] +extra zsh 5.9-1 [installed] +extra SECRET=x 1-1 diff --git a/modules/pacman/cmd/pacman-tools/transaction.go b/modules/pacman/cmd/pacman-tools/transaction.go new file mode 100644 index 00000000..13fcd6ca --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/transaction.go @@ -0,0 +1,409 @@ +package main + +// An upgrade's transaction through an ask (novox/hq ADR 0508 §1, §8): a hand upgrade writes an ask in `asks` at +// `.`; that node's keeper alone takes it and runs the steps: the record, the DKMS gate, the +// transaction as a unit of the service manager started by the root helper (ADR 0510), its end, and the +// after-upgrade check, each written to the run and emitted. A keeper started again finds an unfinished run in +// `runs` and follows it to its end. + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// journalLines is how much of a failed transaction's journal is read for its last error. +const journalLines = 40 + +// thisNode is the node the tools bundle runs on, as the runtime names it; a test replaces it. +var thisNode = func() string { return os.Getenv("MESH_NODE") } + +// AskID names an ask by the moment it was asked and a few random digits, so two asks in one second differ. +func AskID(at time.Time) string { + b := make([]byte, 3) + _, _ = rand.Read(b) + return RunID(at) + "-" + hex.EncodeToString(b) +} + +// AskUpgrade writes an ask for a full upgrade of a node, for that node's keeper to take: what pacman_upgrade +// does (ADR 0508 §8). +func AskUpgrade(asks Bucket, node string, now time.Time) (Ask, error) { + if node == "" { + return Ask{}, errors.New("no node to ask: the runtime names none (MESH_NODE), and none was given") + } + a := Ask{ID: AskID(now), Node: node, Act: ActUpgrade, At: now.UTC()} + if _, err := asks.Create(NodeKey(node, a.ID), a); err != nil { + return Ask{}, fmt.Errorf("writing the ask: %w", err) + } + return a, nil +} + +// lastStep is a run's last step, or none. +func lastStep(r Run) string { + if len(r.Steps) == 0 { + return "" + } + return r.Steps[len(r.Steps)-1].Step +} + +// ended says whether a run has nothing more to do. +func ended(r Run) bool { + switch lastStep(r) { + case StepRefused, StepCheckedAfter: + return true + } + return false +} + +// holdsTheDatabase says whether a run holds a new run back (one run at a time, ADR 0508 §8): only a run the +// mesh ran whose transaction may still go on. A run read from pacman's log is history (ADR 0508 §1), and a run +// whose last step is finished, failed, refused or checked after has no transaction left: its after-upgrade +// check, still to be written by Follow, reads and changes nothing (issue 1841: every run read from the log +// ended at finished and held every ask back). +func holdsTheDatabase(r Run) bool { + if r.From == FromLog { + return false + } + switch lastStep(r) { + case StepFinished, StepFailed, StepRefused, StepCheckedAfter: + return false + } + return true +} + +// ownRuns are the node's runs, oldest first, each with the revision of its key. +func (u *Upgrades) ownRuns() ([]openRun, error) { + keys, err := u.Runs.Keys() + if err != nil { + return nil, fmt.Errorf("listing runs: %w", err) + } + var out []openRun + for _, k := range keys { + if !strings.HasPrefix(k, u.Node+".") { + continue + } + e, err := u.Runs.Get(k) + if err != nil { + return nil, err + } + if e == nil { + continue + } + var r Run + if err := json.Unmarshal(e.Value, &r); err != nil { + return nil, fmt.Errorf("run %s: %w", k, err) + } + out = append(out, openRun{Run: r, rev: e.Revision}) + } + return out, nil +} + +// refuse ends a run that does not start, saying why. +func (u *Upgrades) refuse(r *openRun, why string) error { + if err := u.step(r, Step{Step: StepRefused, At: u.Machine.Now().UTC(), Why: why}); err != nil { + return err + } + return u.emit(EventFailed, UpgradeFailed{Node: r.Node, Run: r.Run.Run, Step: StepRefused, Error: why}) +} + +// StartUpgrade runs a full upgrade's steps up to its transaction's start: the record, the DKMS gate and the +// transaction's unit. The rest follows when the transaction ends (Follow). A run that cannot start is +// written as refused, saying why; only a run that could not be written at all is an error. +func (u *Upgrades) StartUpgrade(from, round, ask string) (Run, error) { + now := u.Machine.Now().UTC() + r := &openRun{Run: Run{Node: u.Node, Run: RunID(now), Kind: RunUpgrade, From: from, Round: round, Ask: ask}} + err := u.startUpgrade(r) + return r.Run, err +} + +func (u *Upgrades) startUpgrade(r *openRun) error { + runs, err := u.ownRuns() + if err != nil { + return err + } + for _, o := range runs { + if o.Run.Run == r.Run.Run { + return fmt.Errorf("run %s of %s is there already: one run a second", o.Run.Run, u.Node) + } + if holdsTheDatabase(o.Run) { + return u.refuse(r, fmt.Sprintf("run %s is still going (its last step is %s): one run at a time", o.Run.Run, lastStep(o.Run))) + } + } + if u.Machine.locked() { + return u.refuse(r, "another pacman holds "+DBLock+": a transaction is running, or one was killed and left its lock") + } + rec, why := u.TakeRecord(r.Run.Run) + if why != "" { + return u.refuse(r, why) + } + if err := u.step(r, Step{Step: StepRecorded, At: u.Machine.Now().UTC()}); err != nil { + return err + } + var errs []error + if err := u.emit(EventRecorded, UpgradeRecorded{Node: u.Node, Run: r.Run.Run, Packages: len(rec.Installed.Packages), Kernel: rec.Kernel}); err != nil { + errs = append(errs, err) + } + held, ignore, why := u.Gate() + if why != "" { + return errors.Join(append(errs, u.refuse(r, why))...) + } + if err := u.step(r, Step{Step: StepCheckedBefore, At: u.Machine.Now().UTC(), Held: held}); err != nil { + return errors.Join(append(errs, err)...) + } + if len(held) > 0 { + if err := u.emit(EventKernelHeld, KernelHeld{Node: u.Node, Run: r.Run.Run, Held: held}); err != nil { + errs = append(errs, err) + } + } + return errors.Join(append(errs, u.start(r, ignore))...) +} + +// start starts the run's transaction through the root helper and writes it started, or failed when it could +// not start, followed by the after-upgrade check. +func (u *Upgrades) start(r *openRun, ignore []string) error { + at := u.Machine.Now().UTC() + unit := TransactionUnit(r.Kind, r.Run.Run) + if err := u.Root.Transaction(r.Kind, r.Run.Run, ignore); err != nil { + return u.end(r, Step{Step: StepFailed, At: u.Machine.Now().UTC(), Error: "the transaction could not be started: " + err.Error()}) + } + if err := u.step(r, Step{Step: StepStarted, At: at, Unit: unit, Ignored: ignore}); err != nil { + return err + } + if r.Kind == RunRollback { + return u.emit(EventRollbackStarted, RollbackStarted{Node: u.Node, Run: r.Run.Run, Of: r.Of, Unit: unit}) + } + return u.emit(EventStarted, UpgradeStarted{Node: u.Node, Run: r.Run.Run, From: r.From, Round: r.Round, Unit: unit, Ignored: ignore}) +} + +// end writes how the transaction ended, emits it, and runs the after-upgrade check. +func (u *Upgrades) end(r *openRun, s Step) error { + if err := u.step(r, s); err != nil { + return err + } + var errs []error + exit := 0 + if s.Exit != nil { + exit = *s.Exit + } + switch { + case r.Kind == RunRollback: + errs = append(errs, u.emit(EventRollbackFinished, RollbackFinished{Node: u.Node, Run: r.Run.Run, Of: r.Of, Exit: exit, Error: s.Error})) + case s.Step == StepFinished: + changes := Changes{} + if s.Changes != nil { + changes = *s.Changes + } + errs = append(errs, u.emit(EventFinished, UpgradeFinished{Node: u.Node, Run: r.Run.Run, Changes: changes})) + default: + errs = append(errs, u.emit(EventFailed, UpgradeFailed{Node: u.Node, Run: r.Run.Run, Step: StepFailed, Exit: s.Exit, Error: s.Error})) + } + return errors.Join(append(errs, u.checkAfter(r))...) +} + +// checkAfter writes the after-upgrade check (ADR 0508 §4) and emits it. +func (u *Upgrades) checkAfter(r *openRun) error { + var rec *Record + key := NodeKey(u.Node, r.Run.Run) + if r.Kind == RunRollback { + key = NodeKey(u.Node, r.Of) + } + if e, err := u.Records.Get(key); err == nil && e != nil { + var got Record + if json.Unmarshal(e.Value, &got) == nil { + rec = &got + } + } + check := u.Check + if check == nil { + check = u.AfterCheck + } + c := check(r.Run, rec) + if err := u.step(r, Step{Step: StepCheckedAfter, At: u.Machine.Now().UTC(), Check: &c}); err != nil { + return err + } + return u.emit(EventChecked, UpgradeChecked{Node: u.Node, Run: r.Run.Run, Kind: r.Kind, Passed: c.Passed}) +} + +// transactionSucceeded says whether a run's transaction finished. +func transactionSucceeded(r Run) bool { + for _, s := range r.Steps { + if s.Step == StepFinished { + return true + } + } + return false +} + +// unitEnd reads a transaction unit's state: whether it ended, and its exit status. A transient unit that ended +// well is let go by the service manager (not found); one that failed stays, failed. +func (u *Upgrades) unitEnd(unit string) (done bool, exit int, err error) { + p, err := u.Machine.unitProps(unit, "LoadState", "ActiveState", "Result", "ExecMainStatus") + if err != nil { + return false, 0, err + } + switch { + case p["LoadState"] == "not-found": + return true, 0, nil + case p["ActiveState"] == "active" || p["ActiveState"] == "activating" || p["ActiveState"] == "deactivating" || p["ActiveState"] == "reloading": + return false, 0, nil + } + exit, _ = strconv.Atoi(p["ExecMainStatus"]) + if exit == 0 && p["Result"] != "success" { + exit = 1 + } + return true, exit, nil +} + +// transactionBlock is the run's transaction as pacman's log tells it: the first command of its kind pacman +// logged since the transaction was started; nil when the log holds none. +func (u *Upgrades) transactionBlock(kind string, since time.Time) *logBlock { + text, err := u.Machine.ReadFile(PacmanLog) + if err != nil { + return nil + } + since = since.Truncate(time.Second) + for _, b := range logBlocks(string(text)) { + if b.Start.Before(since) { + continue + } + if kind == RunUpgrade && !b.Full || kind == RunRollback && !strings.Contains(b.Command, " -U") { + continue + } + return &b + } + return nil +} + +// transactionChanges is what the run's transaction changed, read from its lines in pacman's log. +func (u *Upgrades) transactionChanges(kind string, since time.Time) *Changes { + if b := u.transactionBlock(kind, since); b != nil { + c := b.Changes + return &c + } + return nil +} + +// follow writes the end of a run whose transaction has ended, and its check; a transaction still running is +// left to the next call. +func (u *Upgrades) follow(r *openRun) error { + started := r.Steps[len(r.Steps)-1] + done, exit, err := u.unitEnd(started.Unit) + if err != nil { + return fmt.Errorf("reading %s: %w", started.Unit, err) + } + if !done { + return nil + } + s := Step{Step: StepFinished, At: u.Machine.Now().UTC(), Exit: &exit, Changes: u.transactionChanges(r.Kind, started.At)} + if exit != 0 { + s.Step = StepFailed + lines, err := u.Root.Journal(started.Unit, journalLines) + if err != nil { + s.Error = "(its journal could not be read: " + err.Error() + ")" + } else { + s.Error = lastError(lines) + } + } + return u.end(r, s) +} + +// Follow carries every unfinished run of the node on as far as it can go now: a transaction that ended is +// written finished or failed and checked; a run stopped between its steps by a keeper that went away is taken +// up where it stopped. The keeper calls it at its start and whenever pacman's database lock is released. +func (u *Upgrades) Follow() error { + runs, err := u.ownRuns() + if err != nil { + return err + } + var errs []error + for i := range runs { + r := &runs[i] + if ended(r.Run) || r.From == FromLog { + continue + } + switch lastStep(r.Run) { + case StepStarted: + errs = append(errs, u.follow(r)) + case StepFinished, StepFailed: + errs = append(errs, u.checkAfter(r)) + case StepRecorded, StepCheckedBefore, "": + errs = append(errs, u.resumeBeforeStart(r)) + } + } + return errors.Join(errs...) +} + +// resumeBeforeStart takes up a run whose keeper went away before it wrote the transaction started: when the +// run's unit is there, or pacman's log holds an upgrade since the run's last step, the transaction did start +// and is followed; otherwise nothing started, and the run is refused, so said. +func (u *Upgrades) resumeBeforeStart(r *openRun) error { + unit := TransactionUnit(r.Kind, r.Run.Run) + since := time.Time{} + if n := len(r.Steps); n > 0 { + since = r.Steps[n-1].At + } + p, err := u.Machine.unitProps(unit, "LoadState") + if err != nil { + return fmt.Errorf("reading %s: %w", unit, err) + } + if p["LoadState"] == "not-found" && u.transactionChanges(r.Kind, since) == nil { + return u.refuse(r, "the keeper stopped before it started the transaction, and nothing was started") + } + if err := u.step(r, Step{Step: StepStarted, At: since, Unit: unit}); err != nil { + return err + } + return u.follow(r) +} + +// TakeAsk takes one change of `asks`: an ask for this node, not taken yet, is carried out and then deleted. An +// ask for another node is that node's keeper's. An ask a run already names was taken by a keeper that went +// away before deleting it, and is deleted. The keeper watches `asks` at `.*` and hands each change here. +func (u *Upgrades) TakeAsk(c stdio.StateChange) error { + if c.Op != "put" || !strings.HasPrefix(c.Key, u.Node+".") { + return nil + } + var a Ask + if err := json.Unmarshal(c.Value, &a); err != nil { + return fmt.Errorf("ask %s is not readable: %w", c.Key, err) + } + if a.Node != u.Node { + return fmt.Errorf("ask %s names node %q, not this one", c.Key, a.Node) + } + runs, err := u.ownRuns() + if err != nil { + return err + } + for _, r := range runs { + if r.Ask == a.ID { + return u.Asks.Delete(c.Key) + } + } + switch a.Act { + case ActUpgrade: + r, err := u.StartUpgrade(FromAsk, "", a.ID) + if len(r.Steps) == 0 { + return err // no run names the ask yet: it stays, and is taken again + } + if derr := u.Asks.Delete(c.Key); derr != nil { + err = errors.Join(err, fmt.Errorf("deleting the ask %s: %w", c.Key, derr)) + } + return err + case ActRollback: + return u.takeRollback(c.Key, a) + case ActPass: + if err := u.takePass(a); err != nil { + return err + } + return u.Asks.Delete(c.Key) + default: + return fmt.Errorf("ask %s asks %q, which this keeper does not carry out yet", c.Key, a.Act) + } +} diff --git a/modules/pacman/cmd/pacman-tools/transaction_test.go b/modules/pacman/cmd/pacman-tools/transaction_test.go new file mode 100644 index 00000000..7902d29e --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/transaction_test.go @@ -0,0 +1,427 @@ +package main + +// An upgrade through an ask, and a keeper started again following its run (novox/hq ADR 0508 §1, §8; +// acceptance 1841/AC1 and 1841/AC9). + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// fakeRoot is the root helper: it records the transactions it was asked to start. +type fakeRoot struct { + started []string + fail error + journal []string + mapped []Mapped + mapsErr error +} + +func (f *fakeRoot) Transaction(kind, run string, ignore []string) error { + if f.fail != nil { + return f.fail + } + f.started = append(f.started, kind+" "+run+" ignore="+strings.Join(ignore, ",")) + return nil +} + +func (f *fakeRoot) Rollback(run string, plan []RollbackItem) error { + if f.fail != nil { + return f.fail + } + var names []string + for _, i := range plan { + names = append(names, i.Name+":"+i.To+":"+i.Source) + } + f.started = append(f.started, RunRollback+" "+run+" plan="+strings.Join(names, ",")) + return nil +} + +func (f *fakeRoot) Journal(unit string, n int) ([]string, error) { return f.journal, nil } + +func (f *fakeRoot) Maps() ([]Mapped, error) { return f.mapped, f.mapsErr } + +// keeperBed is one node's keeper over states in memory and a machine of fakes: a unit's state, the lock and +// pacman's log are set by the test as the transaction goes. +type keeperBed struct { + u *Upgrades + root *fakeRoot + events *eventLog + installed, updates, runs, records, asks *memBucket + rounds *memBucket + now time.Time + unitState string // systemctl show's answer for the run's unit + locked bool + log string +} + +const upgradeLog = "[2026-10-04T12:00:01+0000] [PACMAN] Running 'pacman -Syu --noconfirm --ignore linux,linux-headers'\n" + + "[2026-10-04T12:00:02+0000] [PACMAN] starting full system upgrade\n" + + "[2026-10-04T12:00:09+0000] [ALPM] transaction started\n" + + "[2026-10-04T12:00:10+0000] [ALPM] upgraded bash (5.3.15-1 -> 5.3.20-1)\n" + + "[2026-10-04T12:00:10+0000] [ALPM] installed made-up-library (1.0-1)\n" + + "[2026-10-04T12:00:11+0000] [ALPM] transaction completed\n" + +func newKeeperBed(t *testing.T) *keeperBed { + k := &keeperBed{root: &fakeRoot{}, events: &eventLog{}, installed: newBucket(), updates: newBucket(), runs: newBucket(), + records: newBucket(), asks: newBucket(), rounds: newBucket(), now: time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC), unitState: "LoadState=not-found\n"} + saved := fetch + fetch = func(string) ([]byte, error) { return nil, errors.New("offline") } + t.Cleanup(func() { fetch = saved }) + if _, err := k.installed.Put("node-a", nodeAPackages); err != nil { + t.Fatal(err) + } + if _, err := k.updates.Put("node-a", UpdatesValue{Reachable: true, Packages: []VersionChange{ + {Name: "bash", From: "5.3.15-1", To: "5.3.20-1"}, + {Name: "linux", From: "7.2.8.arch1-2", To: "7.3.1.arch1-1"}, + {Name: "linux-headers", From: "7.2.8.arch1-2", To: "7.3.1.arch1-1"}, + }}); err != nil { + t.Fatal(err) + } + sources := capturedZFSSource(t) + files := func(paths ...string) Ran { return Ran{Stdout: strings.Join(paths, "\n") + "\n"} } + m := machine(fake(func(c call) Ran { + line := c.String() + switch { + case line == "systemctl list-units --state=failed --plain --no-legend --no-pager": + return Ran{} + case line == "dkms status": + return Ran{Stdout: zfsStatus} + case line == "pacman -Qq": + return Ran{Stdout: "bash\nlinux\n"} + case line == "pacman -Qlq -- linux": + return files("/usr/lib/modules/7.2.8-arch1-2/vmlinuz") + case line == "pacman -Qlq -- linux-headers": + return files("/usr/lib/modules/7.2.8-arch1-2/build/Makefile") + case strings.HasPrefix(line, "systemctl show "+unitPrefix): + return Ran{Stdout: k.unitState} + } + return Ran{Status: 99, Stderr: "unexpected command: " + line} + }, nil), 1000) + m.Now = func() time.Time { return k.now } + m.ReadFile = func(path string) ([]byte, error) { + switch { + case path == OSRelease: + return []byte("7.2.8-arch1-2\n"), nil + case path == DBLock && k.locked: + return []byte{}, nil + case path == PacmanLog: + return []byte(k.log), nil + } + if s, ok := sources[path]; ok { + return []byte(s), nil + } + return nil, errNoFile + } + k.u = k.keeper(m) + return k +} + +// keeper is a keeper for node-a over the bed's states: a second call is the keeper started again. +func (k *keeperBed) keeper(m *Machine) *Upgrades { + return &Upgrades{Node: "node-a", Machine: m, Root: k.root, Events: k.events, Installed: k.installed, Updates: k.updates, + Runs: k.runs, Records: k.records, Rounds: k.rounds, Asks: k.asks} +} + +// ask writes an ask for node and hands its change to the keeper, as the watch of `asks` would. +func (k *keeperBed) ask(t *testing.T, node, act string) (string, error) { + t.Helper() + a := Ask{ID: RunID(k.now) + "-abc123", Node: node, Act: act, At: k.now} + key := NodeKey(node, a.ID) + rev, err := k.asks.Create(key, a) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(a) + return key, k.u.TakeAsk(stdio.StateChange{State: StateAsks, Key: key, Op: "put", Value: raw, Revision: rev}) +} + +func (k *keeperBed) run(t *testing.T) Run { + t.Helper() + keys, _ := k.runs.Keys() + if len(keys) != 1 { + t.Fatalf("runs: %v", keys) + } + var r Run + k.runs.value(keys[0], &r) + return r +} + +func steps(r Run) string { + var s []string + for _, st := range r.Steps { + s = append(s, st.Step) + } + return strings.Join(s, " ") +} + +func TestAnAskedUpgradeIsARunWrittenAndEmittedAtEachStep(t *testing.T) { + k := newKeeperBed(t) + key, err := k.ask(t, "node-a", ActUpgrade) + if err != nil { + t.Fatal(err) + } + r := k.run(t) + if r.Run != "20261004T120000Z" || r.From != FromAsk || r.Ask != "20261004T120000Z-abc123" || r.Kind != RunUpgrade { + t.Errorf("the run: %+v", r) + } + if steps(r) != "recorded checked-before started" { + t.Fatalf("steps before the transaction ends: %s", steps(r)) + } + if h := r.Steps[1].Held; len(h) != 1 || h[0].Package != "linux" { + t.Errorf("the gate's step names the held kernel: %+v", h) + } + if s := r.Steps[2]; s.Unit != "mesh-pacman-upgrade-20261004t120000z" || strings.Join(s.Ignored, ",") != "linux,linux-headers" { + t.Errorf("started: %+v", s) + } + if strings.Join(k.root.started, ";") != "upgrade 20261004T120000Z ignore=linux,linux-headers" { + t.Errorf("the root helper was asked: %v", k.root.started) + } + if _, there := k.asks.values[key]; there { + t.Error("the ask is deleted once the run names it") + } + var rec Record + if !k.records.value("node-a.20261004T120000Z", &rec) { + t.Error("no record was taken") + } + + // The transaction ends: the lock is released and the keeper follows the run. + k.unitState, k.log, k.now = "LoadState=not-found\n", upgradeLog, k.now.Add(15*time.Second) + if err := k.u.Follow(); err != nil { + t.Fatal(err) + } + r = k.run(t) + if steps(r) != "recorded checked-before started finished checked-after" { + t.Fatalf("steps: %s", steps(r)) + } + end := r.Steps[3] + if end.Exit == nil || *end.Exit != 0 || end.Changes == nil || len(end.Changes.Changed) != 1 || end.Changes.Changed[0].Name != "bash" || + len(end.Changes.Added) != 1 { + t.Errorf("finished: %+v %+v", end, end.Changes) + } + if c := r.Steps[4].Check; c == nil || !c.Passed { + t.Errorf("the check: %+v", c) + } + if got := strings.Join(k.events.names(), " "); got != "upgrade.recorded upgrade.kernel-held upgrade.started upgrade.finished upgrade.checked" { + t.Errorf("events: %s", got) + } + if w := k.runs.writesTo("node-a."); len(w) != 5 || w[0] != "create node-a.20261004T120000Z" { + t.Errorf("each step written once: %v", w) + } + if err := k.u.Follow(); err != nil || len(k.runs.writesTo("node-a.")) != 5 { + t.Errorf("an ended run is not written again: %v", err) + } +} + +func TestAFailedTransactionIsWrittenWithItsExitAndLastErrorAndChecked(t *testing.T) { + k := newKeeperBed(t) + if _, err := k.ask(t, "node-a", ActUpgrade); err != nil { + t.Fatal(err) + } + k.unitState = "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n" + k.root.journal = []string{"checking for file conflicts...", "error: failed to commit transaction (conflicting files)", "Errors occurred, no packages were upgraded."} + if err := k.u.Follow(); err != nil { + t.Fatal(err) + } + r := k.run(t) + end := r.Steps[3] + if steps(r) != "recorded checked-before started failed checked-after" || *end.Exit != 1 || + end.Error != "error: failed to commit transaction (conflicting files)" || r.Steps[4].Check.Passed { + t.Errorf("%s %+v %+v", steps(r), end, r.Steps[4].Check) + } + if got := k.events.names(); got[len(got)-2] != EventFailed || got[len(got)-1] != EventChecked { + t.Errorf("events: %v", got) + } +} + +func TestATransactionThatCannotStartFailsAndIsChecked(t *testing.T) { + k := newKeeperBed(t) + k.root.fail = errors.New("the root helper's socket is not there") + if _, err := k.ask(t, "node-a", ActUpgrade); err != nil { + t.Fatal(err) + } + r := k.run(t) + if steps(r) != "recorded checked-before failed checked-after" || !strings.Contains(r.Steps[2].Error, "socket is not there") { + t.Errorf("%s %+v", steps(r), r.Steps[2]) + } +} + +func TestAnAskIsTakenByItsNodesKeeperAlone(t *testing.T) { + k := newKeeperBed(t) + key, err := k.ask(t, "node-b", ActUpgrade) + if err != nil { + t.Fatal(err) + } + if keys, _ := k.runs.Keys(); len(keys) != 0 || len(k.root.started) != 0 { + t.Errorf("node-a's keeper took node-b's ask: %v %v", keys, k.root.started) + } + if _, there := k.asks.values[key]; !there { + t.Error("another node's ask is left for its keeper") + } + if err := k.u.TakeAsk(stdio.StateChange{State: StateAsks, Key: "node-a.x", Op: "delete"}); err != nil { + t.Errorf("a delete is no ask: %v", err) + } +} + +func TestAnAskARunAlreadyNamesIsDeletedAndNotRunAgain(t *testing.T) { + k := newKeeperBed(t) + done := Run{Node: "node-a", Run: "20261004T115900Z", Kind: RunUpgrade, From: FromAsk, Ask: RunID(k.now) + "-abc123", + Steps: []Step{{Step: StepRefused, At: k.now, Why: "x"}}} + if _, err := k.runs.Create(NodeKey("node-a", done.Run), done); err != nil { + t.Fatal(err) + } + key, err := k.ask(t, "node-a", ActUpgrade) + if err != nil { + t.Fatal(err) + } + if keys, _ := k.runs.Keys(); len(keys) != 1 || len(k.root.started) != 0 { + t.Errorf("taken twice: %v", keys) + } + if _, there := k.asks.values[key]; there { + t.Error("the ask a run names is deleted") + } +} + +func TestARunDoesNotStartWhileTheDatabaseIsLockedOrAnotherRunGoesOn(t *testing.T) { + k := newKeeperBed(t) + k.locked = true + if _, err := k.ask(t, "node-a", ActUpgrade); err != nil { + t.Fatal(err) + } + r := k.run(t) + if steps(r) != "refused" || !strings.Contains(r.Steps[0].Why, DBLock) || len(k.root.started) != 0 { + t.Errorf("locked: %s %+v", steps(r), r.Steps) + } + if e := k.events.sent; len(e) != 1 || e[0].Name != EventFailed || e[0].Body.(UpgradeFailed).Step != StepRefused { + t.Errorf("a refused run is said: %+v", e) + } + + k = newKeeperBed(t) + going := Run{Node: "node-a", Run: "20261004T110000Z", Kind: RunUpgrade, From: FromRound, Steps: []Step{{Step: StepStarted, At: k.now}}} + if _, err := k.runs.Create(NodeKey("node-a", going.Run), going); err != nil { + t.Fatal(err) + } + if _, err := k.u.StartUpgrade(FromAsk, "", "a1"); err != nil { + t.Fatal(err) + } + var r2 Run + k.runs.value("node-a.20261004T120000Z", &r2) + if steps(r2) != "refused" || !strings.Contains(r2.Steps[0].Why, "20261004T110000Z is still going") { + t.Errorf("another run going: %+v", r2) + } +} + +func TestAKeeperStartedAgainFollowsItsRunToItsEnd(t *testing.T) { + k := newKeeperBed(t) + if _, err := k.ask(t, "node-a", ActUpgrade); err != nil { + t.Fatal(err) + } + // The keeper goes away while the transaction runs; a new one starts and finds it still running. + again := k.keeper(k.u.Machine) + k.unitState = "LoadState=loaded\nActiveState=active\nResult=success\nExecMainStatus=0\n" + before := len(k.runs.writes) + if err := again.Follow(); err != nil || len(k.runs.writes) != before { + t.Fatalf("a running transaction is left running: %v, %d writes", err, len(k.runs.writes)-before) + } + k.unitState, k.log = "LoadState=not-found\n", upgradeLog + if err := again.Follow(); err != nil { + t.Fatal(err) + } + if r := k.run(t); steps(r) != "recorded checked-before started finished checked-after" { + t.Errorf("followed to its end: %s", steps(r)) + } +} + +func TestAKeeperStartedAgainTakesUpARunStoppedBetweenItsSteps(t *testing.T) { + // Stopped after the gate, before the transaction started: nothing started, so the run is refused. + k := newKeeperBed(t) + stopped := Run{Node: "node-a", Run: "20261004T115900Z", Kind: RunUpgrade, From: FromAsk, + Steps: []Step{{Step: StepRecorded, At: k.now}, {Step: StepCheckedBefore, At: k.now}}} + if _, err := k.runs.Create(NodeKey("node-a", stopped.Run), stopped); err != nil { + t.Fatal(err) + } + if err := k.u.Follow(); err != nil { + t.Fatal(err) + } + if r := k.run(t); steps(r) != "recorded checked-before refused" || !strings.Contains(r.Steps[2].Why, "nothing was started") { + t.Errorf("%s %+v", steps(r), r.Steps) + } + + // Stopped after the transaction started and before it was written: its log says it ran. + k = newKeeperBed(t) + if _, err := k.runs.Create(NodeKey("node-a", stopped.Run), stopped); err != nil { + t.Fatal(err) + } + k.log = upgradeLog + if err := k.u.Follow(); err != nil { + t.Fatal(err) + } + if r := k.run(t); steps(r) != "recorded checked-before started finished checked-after" || r.Steps[2].Unit != "mesh-pacman-upgrade-20261004t115900z" { + t.Errorf("%s %+v", steps(r), r.Steps) + } + + // Stopped after the transaction ended and before it was checked. + k = newKeeperBed(t) + zero := 0 + unchecked := Run{Node: "node-a", Run: "20261004T115900Z", Kind: RunUpgrade, From: FromAsk, + Steps: []Step{{Step: StepRecorded, At: k.now}, {Step: StepCheckedBefore, At: k.now}, {Step: StepStarted, At: k.now}, {Step: StepFinished, At: k.now, Exit: &zero}}} + if _, err := k.runs.Create(NodeKey("node-a", unchecked.Run), unchecked); err != nil { + t.Fatal(err) + } + if err := k.u.Follow(); err != nil { + t.Fatal(err) + } + if r := k.run(t); steps(r) != "recorded checked-before started finished checked-after" { + t.Errorf("%s", steps(r)) + } +} + +func TestPacmanUpgradeWritesAnAskForTheNodeAndStartsNothing(t *testing.T) { + asks := newBucket() + savedState, savedNode, savedFetch := upgradeState, thisNode, fetch + upgradeState = func(name string) Bucket { + if name != StateAsks { + t.Fatalf("wrote %s", name) + } + return asks + } + thisNode = func() string { return "node-a" } + fetch = func(string) ([]byte, error) { return nil, errors.New("offline") } + defer func() { upgradeState, thisNode, fetch = savedState, savedNode, savedFetch }() + var calls []call + m := machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000) + for _, tool := range tools(m) { + if tool.Name != "pacman_upgrade" { + continue + } + out, err := tool.Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + a := out.(map[string]any)["asked"].(Ask) + if a.Node != "node-a" || a.Act != ActUpgrade || !strings.HasPrefix(a.ID, "20261004T120000Z-") { + t.Errorf("ask: %+v", a) + } + if keys, _ := asks.Keys(); len(keys) != 1 || keys[0] != NodeKey("node-a", a.ID) { + t.Errorf("asks: %v", keys) + } + for _, c := range calls { + if c.name == "sudo" || c.name == "systemd-run" { + t.Errorf("the tool acted itself: %s", c) + } + } + if _, err := tool.Run(map[string]any{"node": "node-b"}); err != nil { + t.Fatal(err) + } + if keys, _ := asks.Keys(); len(keys) != 2 || !strings.HasPrefix(keys[1], "node-b.") { + t.Errorf("another node's ask: %v", keys) + } + return + } + t.Fatal("no pacman_upgrade") +} diff --git a/modules/pacman/cmd/pacman-tools/updates_test.go b/modules/pacman/cmd/pacman-tools/updates_test.go new file mode 100644 index 00000000..6dc0e220 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/updates_test.go @@ -0,0 +1,147 @@ +package main + +// What an upgrade would change (novox/hq ADR 0507 §4; acceptance 1840/AC4): read at the keeper's start, after a +// transaction, after the mirror list is rewritten and a day after the last read; written only when the list or +// reachable changed; a failed read says reachable false and keeps the last list. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" +) + +func updatesOf(t *testing.T, s *fakeStore) UpdatesValue { + t.Helper() + e, _ := s.Get("node-a") + if e == nil { + t.Fatal("no updates.node-a") + } + var v UpdatesValue + if err := json.Unmarshal(e.Value, &v); err != nil { + t.Fatal(err) + } + return v +} + +func (d *pacmanDB) checkupdatesRuns() int { + d.mu.Lock() + defer d.mu.Unlock() + n := 0 + for _, c := range d.commands { + if c == "checkupdates" { + n++ + } + } + return n +} + +func TestUpdatesAreWrittenOnlyWhenTheListOrReachableChanged(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Stdout: "zsh 5.9-1 -> 5.9-2\nlinux 6.17.1-1 -> 6.17.2-1\n"}} + db.set("glibc 2.42-1\n", "") + k, states, _, _, _ := newTestKeeper(db) + up := states.store(StateUpdates) + k.refreshUpdates() + if w := up.Writes(); strings.Join(w, ",") != "put node-a" { + t.Fatalf("writes: %v", w) + } + if v := updatesOf(t, up); !v.Reachable || fmt.Sprint(v.Packages) != "[{linux 6.17.1-1 6.17.2-1} {zsh 5.9-1 5.9-2}]" { + t.Fatalf("%+v", v) + } + // the same answer: nothing written. + k.refreshUpdates() + if w := up.Writes(); len(w) != 0 { + t.Fatalf("the same list was written again: %v", w) + } + // the mirrors cannot be reached: reachable false, the last list kept. + db.setUpdates(Ran{Status: 1, Stderr: "==> ERROR: Cannot fetch updates\n"}) + k.refreshUpdates() + if w := up.Writes(); len(w) != 1 { + t.Fatalf("writes: %v", w) + } + if v := updatesOf(t, up); v.Reachable || len(v.Packages) != 2 { + t.Fatalf("a failed read keeps the list and says reachable false: %+v", v) + } + k.refreshUpdates() + if w := up.Writes(); len(w) != 0 { + t.Fatalf("still unreachable, and written again: %v", w) + } + // reached again with nothing to do (status 2): an empty list, reachable. + db.setUpdates(Ran{Status: 2}) + k.refreshUpdates() + if v := updatesOf(t, up); !v.Reachable || v.Packages == nil || len(v.Packages) != 0 { + t.Fatalf("%+v", v) + } +} + +func TestAFirstReadThatFailsSaysUnreachableNeverAnEmptyListAsAnAnswer(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 1, Stderr: "==> ERROR: Cannot fetch updates\n"}} + k, states, _, _, _ := newTestKeeper(db) + k.refreshUpdates() + if v := updatesOf(t, states.store(StateUpdates)); v.Reachable { + t.Fatalf("%+v", v) + } + raw, _ := states.store(StateUpdates).Get("node-a") + if string(raw.Value) != `{"reachable":false,"packages":[]}` { + t.Errorf("%s", raw.Value) + } +} + +// Read at the start, after a transaction, after the mirror list is rewritten, and a day after the last read +// with nothing else asking; never in between. +func TestUpdatesAreReadAtStartAfterATransactionAfterTheMirrorsAndDaily(t *testing.T) { + db := &pacmanDB{sl: syncDBs, checkup: Ran{Status: 2}} + db.set("glibc 2.42-1\n", "") + k, states, _, clock, w := newTestKeeper(db) + var mu sync.Mutex + var seen []string + k.Hooks.AfterTransaction = []func(*Keeper, Transaction) error{func(*Keeper, Transaction) error { + mu.Lock() + seen = append(seen, "transaction") + mu.Unlock() + return nil + }} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- k.Run(ctx) }() + reads := func(want int) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + if db.checkupdatesRuns() == want { + return + } + } + t.Fatalf("checkupdates ran %d times, want %d", db.checkupdatesRuns(), want) + } + reads(1) + db.setUpdates(Ran{Stdout: "zsh 5.9-1 -> 5.9-2\n"}) + clock.Advance(time.Hour) + w.c <- MirrorsRewritten + reads(2) + db.setUpdates(Ran{Status: 2}) + w.c <- LockReleased + reads(3) + waitFor(t, &mu, &seen, "transaction") + clock.Advance(UpdatesEvery - time.Minute) + time.Sleep(50 * time.Millisecond) + if n := db.checkupdatesRuns(); n != 3 { + t.Fatalf("read before a day passed: %d", n) + } + db.setUpdates(Ran{Stdout: "glibc 2.42-1 -> 2.42-2\n"}) + clock.Advance(time.Minute) + reads(4) + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { + if e, _ := states.store(StateUpdates).Get("node-a"); e != nil && strings.Contains(string(e.Value), "glibc") { + break + } + } + if w := states.store(StateUpdates).Writes(); len(w) != 4 { + t.Errorf("each read that changed the list wrote it, and only those: %v", w) + } + cancel() + <-done +} diff --git a/modules/pacman/cmd/pacman-tools/upgrade_health.go b/modules/pacman/cmd/pacman-tools/upgrade_health.go index 90180130..f73f1095 100644 --- a/modules/pacman/cmd/pacman-tools/upgrade_health.go +++ b/modules/pacman/cmd/pacman-tools/upgrade_health.go @@ -1,18 +1,30 @@ package main +// The module's health, read from the bus (novox/hq ADR 0508 §11; issue 1579). The health check of the keeper's +// process reads this node's latest run, the week's round and any held kernel from the module's states, never a +// unit's age, so a laptop that slept through a run, or a node that was off, is judged by what the bus holds. It +// is unhealthy, naming why, when: +// +// - `installed.` is marked not current (ADR 0507 §2); +// - no `upgrade-order` is set: no round starts, and every node says so (ADR 0508 §5); +// - the node's latest run was refused, failed, or its after-upgrade check did not pass, until a later run passes; +// - the latest run's transaction goes on past RunLimit: hung, holding pacman's lock; +// - the latest run held a kernel, naming the package, the DKMS module and its bound (ADR 0508 §3); +// - the week's round stands still: this node's turn came more than round-start-minutes ago and it has not +// started, or the node it waits for stalled or failed, named with since when (ADR 0508 §6). +// +// A state that cannot be read is unhealthy: failure is loud. + import ( + "encoding/json" + "errors" "fmt" + "os" + "sort" "strings" "time" ) -// The weekly upgrade's outcome (issue 1574, ADR 0477 clause 5). The mesh runs a full upgrade on every node -// each Saturday at 04:00 local time, from a timer and a oneshot service of the service manager -// (files/mesh-pacman-weekly-upgrade.*). Its outcome is the module's health: this tool is the health check of -// the timer's resource, so a failed upgrade raises the module's condition on the node, naming the node and, -// here, the error. A unit that failed stays failed until the next run, so the health stays unhealthy until an -// upgrade passes. - // Health is the module's health, as the node-engine asks it (novox/hq ADR 0240): unhealthy with why. type Health struct { Healthy bool `json:"healthy"` @@ -20,66 +32,190 @@ type Health struct { Found []string `json:"found,omitempty"` } -// WeeklyUnit is the service the weekly timer starts. -const WeeklyUnit = "mesh-pacman-weekly-upgrade.service" - -// RunLimit is how long a weekly upgrade may go on before it is unhealthy. The service has no start timeout on -// purpose: killing pacman in the middle of a transaction leaves a half-upgraded machine. A run past the limit -// is a hung one (a stuck hook, a stalled transaction), which holds pacman's database lock so that no later run -// can start; it is said, not killed. +// RunLimit is how long a run's transaction may go on before it is unhealthy. A transaction has no timeout on +// purpose: killing pacman in the middle of one leaves a half-upgraded machine. One past the limit is a hung one +// (a stuck hook, a stalled download), which holds pacman's database lock so no later run can start; it is said, +// not killed. const RunLimit = 2 * time.Hour -// systemdTime is how systemctl shows a timestamp, in the machine's local time. -const systemdTime = "Mon 2006-01-02 15:04:05 MST" +// healthTime is how the health writes a moment: the machine's local time. +const healthTime = "2006-01-02 15:04 MST" -// UpgradeHealth judges the last weekly upgrade from its service: unhealthy when it failed, naming the exit -// and the last error the journal holds; healthy while it runs, after it passed, and before it ever ran. A -// unit that cannot be read is unhealthy: failure is loud. -func (m *Machine) UpgradeHealth() Health { - p, err := m.unitProps(WeeklyUnit, "LoadState", "ActiveState", "Result", "ExecMainStatus", "ExecMainExitTimestamp", "ExecMainStartTimestamp") - if err != nil { - return Health{Why: "the weekly upgrade's service could not be read: " + err.Error()} +// keeperConfig is the keeper's config as the tools bundle reads it, from the file the manifest names to the +// bundle too (EnvConfig); a test replaces it. A file that cannot be read is an empty config: no order. +var keeperConfig = func() KeeperConfig { + var c KeeperConfig + if raw, err := os.ReadFile(os.Getenv(EnvConfig)); err == nil { + _ = json.Unmarshal(raw, &c) } - switch { - case p["LoadState"] == "not-found": - return Health{Healthy: true, Why: "the weekly upgrade's service is not placed yet; no upgrade has run"} - case p["ActiveState"] == "activating" || p["ActiveState"] == "active": - return m.runningHealth(p["ExecMainStartTimestamp"]) - case p["ActiveState"] != "failed" && p["Result"] == "success": - if at := p["ExecMainExitTimestamp"]; at != "" { - return Health{Healthy: true, Why: "the last weekly upgrade passed (" + at + ")"} - } - return Health{Healthy: true, Why: "no weekly upgrade has run yet"} - } - why := fmt.Sprintf("the weekly upgrade failed (%s, status %s)", p["Result"], p["ExecMainStatus"]) - if at := p["ExecMainExitTimestamp"]; at != "" { - why += " at " + at - } - found := []string{} - if said := lastError(m.journal(WeeklyUnit, 40)); said != "" { - why += ": " + said - found = append(found, said) - } - return Health{Why: why + "; the next upgrade is the next Saturday 04:00, or a hand pacman_upgrade", Found: found} + return c } -// runningHealth judges a run in progress: healthy within RunLimit of its start, unhealthy past it, naming the -// start and the last line the journal holds. -func (m *Machine) runningHealth(started string) Health { - at, err := time.ParseInLocation(systemdTime, started, time.Local) +// readOnly is a state the tools bundle reads, as the round's readers take it; it writes nothing. +type readOnly struct{ StateReader } + +var errReadOnly = errors.New("the health writes nothing") + +func (readOnly) Create(string, any) (uint64, error) { return 0, errReadOnly } +func (readOnly) Update(string, any, uint64) (uint64, error) { return 0, errReadOnly } +func (readOnly) Put(string, any) (uint64, error) { return 0, errReadOnly } +func (readOnly) Delete(string) error { return errReadOnly } + +// UpgradeHealth is the health of this node's upgrades, from the module's states. +func (m *Machine) UpgradeHealth() Health { + if h, said := m.InstalledHealth(); said { + return h + } + if m.States == nil || m.Node == "" { + return Health{Why: "the tools bundle was started without its node (MESH_NODE) or the bus: the upgrades cannot be read"} + } + cfg := keeperConfig() + if len(cfg.UpgradeOrder) == 0 { + return Health{Why: "no upgrade-order is set: no node upgrades, since no round starts until the mesh-level key " + + "upgrade-order names the nodes in the order they upgrade (ADR 0508 §5)"} + } + runs := readOnly{m.States(StateRuns)} + latest, err := latestOwnRun(runs, m.Node) if err != nil { - return Health{Why: fmt.Sprintf("the weekly upgrade is running and when it started could not be read (%q)", started)} + return Health{Why: "the node's runs could not be read: " + err.Error()} } - if m.Now().Sub(at) <= RunLimit { - return Health{Healthy: true, Why: "the weekly upgrade is running, since " + started} + if latest != nil { + if h, said := m.runHealth(*latest); said { + return h + } } - why := fmt.Sprintf("the weekly upgrade has been running since %s, longer than %s: it is hung, and holds pacman's database lock so no later upgrade can start", started, RunLimit) - found := []string{} - if ls := m.journal(WeeklyUnit, 1); len(ls) > 0 && !strings.HasPrefix(ls[0], "(the journal could not be read") { - why += "; its last line: " + strings.TrimSpace(ls[0]) - found = append(found, strings.TrimSpace(ls[0])) + if h, said := m.roundHealth(readOnly{m.States(StateRounds)}, runs, cfg.RoundStartMinutes); said { + return h } - return Health{Why: why, Found: found} + if latest == nil { + return Health{Healthy: true, Why: "no upgrade has run on this node through the mesh yet"} + } + return Health{Healthy: true, Why: fmt.Sprintf("the last run, %s, passed its after-upgrade check", latest.Run)} +} + +// latestOwnRun is the node's newest run that the mesh ran, or nil; runs read from pacman's log are history. +func latestOwnRun(runs Bucket, node string) (*Run, error) { + keys, err := runs.Keys() + if err != nil { + return nil, err + } + sort.Sort(sort.Reverse(sort.StringSlice(keys))) + for _, k := range keys { + if !strings.HasPrefix(k, node+".") { + continue + } + e, err := runs.Get(k) + if err != nil { + return nil, err + } + if e == nil { + continue + } + var r Run + if err := json.Unmarshal(e.Value, &r); err != nil { + return nil, fmt.Errorf("run %s: %w", k, err) + } + if r.From == FromLog || len(r.Steps) == 0 { + continue + } + return &r, nil + } + return nil, nil +} + +// runHealth judges the node's latest run; said is false when the run has nothing to say against the node. +func (m *Machine) runHealth(r Run) (Health, bool) { + var held []HeldKernel + var started *Step + for i, s := range r.Steps { + switch s.Step { + case StepCheckedBefore: + held = s.Held + case StepStarted: + started = &r.Steps[i] + } + } + what := r.Kind + " run " + r.Run + last := r.Steps[len(r.Steps)-1] + switch lastStep(r) { + case StepRefused: + return Health{Why: fmt.Sprintf("the %s did not start (%s): %s", what, last.At.Local().Format(healthTime), last.Why)}, true + case StepCheckedAfter: + if last.Check == nil || !last.Check.Passed { + why := fmt.Sprintf("the %s did not pass its after-upgrade check (%s)", what, last.At.Local().Format(healthTime)) + for _, s := range r.Steps { + if s.Step == StepFailed { + why += ": its transaction failed: " + s.Error + } + } + if last.Check != nil { + for _, u := range last.Check.Unbuilt { + why += fmt.Sprintf("; %s %s is not built for kernel %s", u.Module, u.Version, u.Kernel) + } + } + return Health{Why: why + "; pacman_upgrades with the node and the run says it whole", Found: []string{r.Run}}, true + } + case StepStarted: + if started != nil && m.Now().Sub(started.At) > RunLimit { + return Health{Why: fmt.Sprintf("the %s's transaction (%s) has gone on since %s, longer than %s: it is hung, and holds pacman's database lock so no later run can start", + what, started.Unit, started.At.Local().Format(healthTime), RunLimit)}, true + } + } + if len(held) > 0 { + var said []string + for _, h := range held { + said = append(said, fmt.Sprintf("%s %s (the DKMS module %s allows %s)", h.Package, h.Version, h.Module, h.Bound)) + } + return Health{Why: "the " + what + " held back " + strings.Join(said, ", ") + ": the kernel waits until its DKMS module follows (ADR 0508 §3)"}, true + } + return Health{}, false +} + +// roundHealth judges this node's place in the newest round; said is false when the round has nothing to say +// against it. +func (m *Machine) roundHealth(rounds, runs Bucket, startMinutes int) (Health, bool) { + id, err := latestRound(rounds) + if err != nil { + return Health{Why: "the rounds could not be read: " + err.Error()}, true + } + if id == "" { + return Health{}, false + } + v, err := readRound(rounds, runs, id) + if err != nil { + return Health{Why: "round " + id + " could not be read: " + err.Error()}, true + } + if v == nil || !contains(v.Round.Order, m.Node) { + return Health{}, false + } + if startMinutes < 1 { + startMinutes = defaultRoundStartMinutes + } + byNode := map[string]NodeInRound{} + for _, s := range RoundState(v, m.Now(), startMinutes) { + byNode[s.Node] = s + } + since := func(s NodeInRound) string { + if s.Since == nil { + return "an unknown time" + } + return s.Since.Local().Format(healthTime) + } + me := byNode[m.Node] + switch me.State { + case InRoundStalled: + return Health{Why: fmt.Sprintf("round %s: this node's turn came at %s and it has not started its run within %d minutes", id, since(me), startMinutes)}, true + case InRoundWaiting: + if w, ok := byNode[me.WaitsFor]; ok { + switch w.State { + case InRoundStalled: + return Health{Why: fmt.Sprintf("round %s stands still: this node waits for %s, whose turn came at %s and which has not started (since %s)", id, w.Node, since(w), since(w))}, true + case InRoundFailed: + return Health{Why: fmt.Sprintf("round %s stopped at %s, whose run or check failed at %s: this node waits on it since then; only the operator passes it (pacman_round with pass and a reason)", id, w.Node, since(w))}, true + } + } + } + return Health{}, false } // lastError is the last line pacman marked as an error, else the last line the journal holds; a journal that diff --git a/modules/pacman/cmd/pacman-tools/upgrade_health_test.go b/modules/pacman/cmd/pacman-tools/upgrade_health_test.go new file mode 100644 index 00000000..86690236 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/upgrade_health_test.go @@ -0,0 +1,147 @@ +package main + +// The module's health read from the bus (novox/hq ADR 0508 §3, §5, §6, §11; acceptance 1841/AC6, AC7, AC11, and +// issue 1579): the node's latest run, its round and any held kernel are read from the module's states, never a +// unit's age, so a laptop that slept through a run is judged by what the bus holds. + +import ( + "strings" + "testing" + "time" +) + +// the test machine's clock is 2026-10-04 12:00 UTC (machine_test.go) +var healthNow = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) + +func healthBed(t *testing.T, order []string) (*Machine, *fakeStates) { + t.Helper() + states := &fakeStates{} + m := machine(nil, 1000) + m.Node, m.States = "node-b", readerOf(states) + saved := keeperConfig + keeperConfig = func() KeeperConfig { return KeeperConfig{UpgradeOrder: order, RoundStartMinutes: 120, RecordDays: 180} } + t.Cleanup(func() { keeperConfig = saved }) + return m, states +} + +func putRun(states *fakeStates, r Run) { states.store(StateRuns).Put(NodeKey(r.Node, r.Run), r) } + +func passedRun(node string, at time.Time) Run { + return Run{Node: node, Run: RunID(at), Kind: RunUpgrade, From: FromRound, Steps: []Step{ + {Step: StepRecorded, At: at}, {Step: StepCheckedBefore, At: at}, {Step: StepStarted, At: at}, + {Step: StepFinished, At: at.Add(5 * time.Minute)}, {Step: StepCheckedAfter, At: at.Add(6 * time.Minute), Check: &Check{Passed: true}}}} +} + +func TestWithNoOrderEveryNodesHealthSaysWhy(t *testing.T) { + m, _ := healthBed(t, nil) + if h := m.UpgradeHealth(); h.Healthy || !strings.Contains(h.Why, "upgrade-order") { + t.Fatalf("%+v", h) + } +} + +func TestANodeWithNoRunYetIsHealthy(t *testing.T) { + m, _ := healthBed(t, []string{"node-a", "node-b"}) + if h := m.UpgradeHealth(); !h.Healthy { + t.Fatalf("%+v", h) + } +} + +// issue 1579: a run that failed is unhealthy until a later run passes, whenever the node woke; a run read from +// the log is history and judges nothing. +func TestTheLatestRunJudgesTheNodeNotAUnitsAge(t *testing.T) { + m, states := healthBed(t, []string{"node-a", "node-b"}) + exit := 1 + failedAt := healthNow.Add(-7 * 24 * time.Hour) + putRun(states, Run{Node: "node-b", Run: RunID(failedAt), Kind: RunUpgrade, From: FromRound, Steps: []Step{ + {Step: StepStarted, At: failedAt}, {Step: StepFailed, At: failedAt, Exit: &exit, Error: "error: failed to commit transaction (conflicting files)"}, + {Step: StepCheckedAfter, At: failedAt, Check: &Check{}}}}) + putRun(states, Run{Node: "node-b", Run: RunID(healthNow.Add(-time.Hour)), Kind: RunUpgrade, From: FromLog, + Steps: []Step{{Step: StepStarted, At: healthNow.Add(-time.Hour)}, {Step: StepFinished, At: healthNow.Add(-time.Hour)}}}) + putRun(states, passedRun("node-a", healthNow.Add(-time.Minute))) + h := m.UpgradeHealth() + if h.Healthy || !strings.Contains(h.Why, "conflicting files") || !strings.Contains(h.Why, RunID(failedAt)) { + t.Fatalf("a failed run: %+v", h) + } + putRun(states, passedRun("node-b", healthNow.Add(-30*time.Minute))) + if h := m.UpgradeHealth(); !h.Healthy { + t.Fatalf("a later run passed: %+v", h) + } +} + +func TestAFailedCheckIsUnhealthy(t *testing.T) { + m, states := healthBed(t, []string{"node-b"}) + r := passedRun("node-b", healthNow.Add(-time.Hour)) + r.Steps[len(r.Steps)-1].Check = &Check{Passed: false, Unbuilt: []DKMSBuild{{Module: "made-up-dkms", Version: "1.0", Kernel: "7.3.1-arch1-1"}}} + putRun(states, r) + if h := m.UpgradeHealth(); h.Healthy || !strings.Contains(h.Why, "after-upgrade check") { + t.Fatalf("%+v", h) + } +} + +func TestARunPastTheLimitIsHungAndOneWithinItIsHealthy(t *testing.T) { + m, states := healthBed(t, []string{"node-b"}) + started := healthNow.Add(-3 * time.Hour) + putRun(states, Run{Node: "node-b", Run: RunID(started), Kind: RunUpgrade, From: FromAsk, Steps: []Step{{Step: StepRecorded, At: started}, {Step: StepStarted, At: started, Unit: TransactionUnit(RunUpgrade, RunID(started))}}}) + if h := m.UpgradeHealth(); h.Healthy || !strings.Contains(h.Why, started.Local().Format(healthTime)) { + t.Fatalf("3 h: %+v", h) + } + m2, states2 := healthBed(t, []string{"node-b"}) + started = healthNow.Add(-10 * time.Minute) + putRun(states2, Run{Node: "node-b", Run: RunID(started), Kind: RunUpgrade, From: FromAsk, Steps: []Step{{Step: StepStarted, At: started}}}) + if h := m2.UpgradeHealth(); !h.Healthy { + t.Fatalf("10 min: %+v", h) + } +} + +// 1841/AC4: a held kernel keeps the health unhealthy, naming it, until a later run upgrades it. +func TestAHeldKernelIsSaidUntilARunUpgradesIt(t *testing.T) { + m, states := healthBed(t, []string{"node-b"}) + r := passedRun("node-b", healthNow.Add(-2*time.Hour)) + r.Steps[1].Held = []HeldKernel{{Package: "linux", Version: "7.3.1.arch1-1", Module: "zfs", Bound: "Linux-Maximum 7.2"}} + putRun(states, r) + h := m.UpgradeHealth() + if h.Healthy || !strings.Contains(h.Why, "linux 7.3.1.arch1-1") || !strings.Contains(h.Why, "zfs") || !strings.Contains(h.Why, "Linux-Maximum 7.2") { + t.Fatalf("%+v", h) + } + putRun(states, passedRun("node-b", healthNow.Add(-time.Hour))) + if h := m.UpgradeHealth(); !h.Healthy { + t.Fatalf("a later run held nothing: %+v", h) + } +} + +// 1841/AC7: a turn not started within round-start-minutes makes the waiting node's health name the node it +// waits for and since when; the stalled node's own health says it too. +func TestAStalledRoundIsSaidByTheNodesWaitingOnIt(t *testing.T) { + order := []string{"node-a", "node-b", "node-c"} + m, states := healthBed(t, order) + round := RoundID(healthNow) + opened := healthNow.Add(-3 * time.Hour) + states.store(StateRounds).Put(round, Round{Round: round, Opened: opened, OpenedBy: "node-a", Order: order}) + putRun(states, passedRun("node-a", opened)) + states.store(StateRounds).Put(RoundNodeKey(round, "node-a"), RoundNode{Round: round, Node: "node-a", Run: RunID(opened)}) + // node-a passed 2 h 54 min ago; node-b never started. + h := m.UpgradeHealth() + if h.Healthy || !strings.Contains(h.Why, "has not started") { + t.Fatalf("the stalled node: %+v", h) + } + m.Node = "node-c" + h = m.UpgradeHealth() + if h.Healthy || !strings.Contains(h.Why, "node-b") || !strings.Contains(h.Why, "since") { + t.Fatalf("the node waiting on it: %+v", h) + } +} + +func TestARoundStoppedByAFailureIsSaidByTheNodesWaitingOnIt(t *testing.T) { + order := []string{"node-a", "node-b"} + m, states := healthBed(t, order) + round := RoundID(healthNow) + at := healthNow.Add(-time.Hour) + states.store(StateRounds).Put(round, Round{Round: round, Opened: at, OpenedBy: "node-a", Order: order}) + r := passedRun("node-a", at) + r.Steps[len(r.Steps)-1].Check = &Check{Passed: false} + putRun(states, r) + states.store(StateRounds).Put(RoundNodeKey(round, "node-a"), RoundNode{Round: round, Node: "node-a", Run: r.Run}) + if h := m.UpgradeHealth(); h.Healthy || !strings.Contains(h.Why, "node-a") || !strings.Contains(h.Why, "stopped") { + t.Fatalf("%+v", h) + } +} diff --git a/modules/pacman/cmd/pacman-tools/upgrades.go b/modules/pacman/cmd/pacman-tools/upgrades.go new file mode 100644 index 00000000..1ed15457 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/upgrades.go @@ -0,0 +1,409 @@ +package main + +// Upgrades as runs on the bus (novox/hq ADR 0508 §1): every full upgrade of a node, and every rollback, is a run +// in the state `runs` at `.`, written at each of its steps and at no other time, and each step is also +// an event. The keeper's loop calls what is here; everything it reaches is behind an interface (a state, the +// events, the root acts) or the Machine's injected runner and files, so it is tested without a bus or a machine. + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Bucket is one of the module's states as a writer sees it: a process's state, or a tools bundle's +// (stdio.KeptState). A write that lost a race is stdio.ErrChanged. +type Bucket interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) + Put(key string, value any) (uint64, error) + Delete(key string) error + Keys() ([]string, error) +} + +// EventSink emits the keeper's events: the process's connection (process.Conn). +type EventSink interface { + Emit(eventType string, body any, opts ...stdio.EmitOptions) error +} + +// RootActs are the acts that need root, carried out by the module's root helper (ADR 0510): the keeper never +// runs them itself. +type RootActs interface { + // Transaction starts the run's transaction as a unit of the service manager, named TransactionUnit(kind, + // run), leaving out the packages ignore names, and answers once it started. + Transaction(kind, run string, ignore []string) error + // Rollback starts a rollback's transaction as a unit of the service manager, named TransactionUnit(RunRollback, + // run), carrying out the plan in the request (ADR 0510: the plan travels with the verb), and answers once it + // started. + Rollback(run string, plan []RollbackItem) error + // Journal is the last n lines a transaction unit logged. + Journal(unit string, n int) ([]string, error) + // Maps is every process still mapping a replaced file, by its unit or container and its executable. + Maps() ([]Mapped, error) +} + +// TransactionUnit is the unit a run's transaction runs as: named by the run, so a keeper started again finds it. +func TransactionUnit(kind, run string) string { + return unitPrefix + kind + "-" + strings.ToLower(run) +} + +// Upgrades is one node's upgrades: what the keeper's loop calls for its own node. +type Upgrades struct { + Node string + Config KeeperConfig + Machine *Machine + Root RootActs + Events EventSink + // The module's states. + Installed, Updates, Runs, Records, Rounds, Asks Bucket + // AskRecord reads the operator channel's record of one of this module's asks (process.Conn.Ask on + // MessengerAsksBucket): how a pass is answered. + AskRecord func(id string) (*stdio.StateEntry, error) + // Check is the after-upgrade check (ADR 0508 §4); nil is AfterCheck. + Check func(run Run, record *Record) Check +} + +// openRun is a run being written, with the revision of its key. +type openRun struct { + Run + rev uint64 +} + +// step writes the run with one more step: created at its first step, then only ever updated from the revision +// this writer wrote, so a second writer is refused rather than overwritten. +func (u *Upgrades) step(r *openRun, s Step) error { + r.Steps = append(r.Steps, s) + key := NodeKey(r.Node, r.Run.Run) + var err error + if r.rev == 0 { + r.rev, err = u.Runs.Create(key, r.Run) + } else { + r.rev, err = u.Runs.Update(key, r.Run, r.rev) + } + if err != nil { + r.Steps = r.Steps[:len(r.Steps)-1] + return fmt.Errorf("writing step %s of run %s: %w", s.Step, key, err) + } + return nil +} + +// emit says a step as its event; an event that is not sent is said in the error, the step stays written. +func (u *Upgrades) emit(event string, body any) error { + if u.Events == nil { + return nil + } + if err := u.Events.Emit(event, body); err != nil { + return fmt.Errorf("emitting %s: %w", event, err) + } + return nil +} + +// The package manager's log, read as blocks: each begins at a `Running ''` line (or a full upgrade +// starting with none before it) and holds the transaction it ran and the packages it changed. +var ( + runningLine = regexp.MustCompile(`^\[([^\]]+)\] \[PACMAN\] Running '(.*)'$`) + transactionLine = regexp.MustCompile(`^\[([^\]]+)\] \[ALPM\] transaction (started|completed|failed|interrupted)$`) + pacnewLine = regexp.MustCompile(`^\[[^\]]+\] \[ALPM\] warning: \S+ installed as (\S+\.pacnew)$`) + pacsaveLine = regexp.MustCompile(`^\[[^\]]+\] \[ALPM\] warning: \S+ saved as (\S+\.pacsave)$`) +) + +// logBlock is one command pacman ran, as its log tells it. +type logBlock struct { + Start time.Time + Command string + Full bool // it started a full system upgrade + FullAt time.Time // when + Began bool // a transaction started + End string // completed, failed or interrupted; empty when the log holds no end + EndAt time.Time + Changes Changes + // The .pacnew and .pacsave files the transaction left, by path, in the order logged. + Pacnew, Pacsave []string +} + +// logBlocks reads pacman's log into the commands it ran, oldest first. +func logBlocks(text string) []logBlock { + var blocks []logBlock + var cur *logBlock + open := func(at time.Time, command string) { + blocks = append(blocks, logBlock{Start: at, Command: command}) + cur = &blocks[len(blocks)-1] + } + for _, l := range strings.Split(text, "\n") { + l = strings.TrimRight(l, "\r") + if m := runningLine.FindStringSubmatch(l); m != nil { + if t, ok := logTime(m[1]); ok { + open(t, m[2]) + } + continue + } + if m := fullUpdate.FindStringSubmatch(l); m != nil { + t, ok := logTime(m[1]) + if !ok { + continue + } + if cur == nil || cur.Full || cur.Began { + open(t, "") + } + cur.Full, cur.FullAt = true, t + continue + } + if cur == nil { + continue + } + if m := transactionLine.FindStringSubmatch(l); m != nil { + t, _ := logTime(m[1]) + if m[2] == "started" { + cur.Began = true + } else if cur.Began && cur.End == "" { + cur.End, cur.EndAt = m[2], t + } + continue + } + if cur.Began && cur.End == "" { + if m := logLine.FindStringSubmatch(l); m != nil { + addChange(&cur.Changes, m[2], m[3], m[4]) + } else if m := pacnewLine.FindStringSubmatch(l); m != nil { + cur.Pacnew = append(cur.Pacnew, m[1]) + } else if m := pacsaveLine.FindStringSubmatch(l); m != nil { + cur.Pacsave = append(cur.Pacsave, m[1]) + } + } + } + for i := range blocks { + sortChanges(&blocks[i].Changes) + } + return blocks +} + +func addChange(c *Changes, action, name, version string) { + switch action { + case "installed": + c.Added = append(c.Added, PackageVersion{Name: name, Version: version}) + case "removed": + c.Removed = append(c.Removed, PackageVersion{Name: name, Version: version}) + case "upgraded", "downgraded": + from, to, _ := strings.Cut(version, " -> ") + c.Changed = append(c.Changed, VersionChange{Name: name, From: from, To: to}) + } +} + +func sortChanges(c *Changes) { + if c.Added == nil { + c.Added = []PackageVersion{} + } + if c.Removed == nil { + c.Removed = []PackageVersion{} + } + if c.Changed == nil { + c.Changed = []VersionChange{} + } + sort.Slice(c.Added, func(i, j int) bool { return c.Added[i].Name < c.Added[j].Name }) + sort.Slice(c.Removed, func(i, j int) bool { return c.Removed[i].Name < c.Removed[j].Name }) + sort.Slice(c.Changed, func(i, j int) bool { return c.Changed[i].Name < c.Changed[j].Name }) +} + +// noTransaction is why a full upgrade the log tells of has no transaction: the log does not say which. +const noTransaction = "the log holds no transaction for this upgrade: there was nothing to do, or it ended before changing anything" + +// PastUpgrades is every full upgrade pacman's log tells of, as runs read from the log (ADR 0508 §1): started +// when the upgrade started, finished or failed when its transaction ended, with what it changed. One whose +// transaction has no end in the log failed, so said. +func PastUpgrades(node, text string) []Run { + var runs []Run + for _, b := range logBlocks(text) { + if !b.Full { + continue + } + r := Run{Node: node, Run: RunID(b.FullAt), Kind: RunUpgrade, From: FromLog, + Steps: []Step{{Step: StepStarted, At: b.FullAt}}} + changes := b.Changes + switch { + case !b.Began: + r.Steps = append(r.Steps, Step{Step: StepFinished, At: b.FullAt, Why: noTransaction, Changes: &changes}) + case b.End == "completed": + r.Steps = append(r.Steps, Step{Step: StepFinished, At: b.EndAt, Changes: &changes}) + case b.End == "": + r.Steps = append(r.Steps, Step{Step: StepFailed, At: b.FullAt, Error: "the log holds no end of this transaction", Changes: &changes}) + default: + r.Steps = append(r.Steps, Step{Step: StepFailed, At: b.EndAt, Error: "transaction " + b.End, Changes: &changes}) + } + runs = append(runs, r) + } + return runs +} + +// ImportLog reads the node's past full upgrades from pacman's log into `runs` once: when the node has no run +// yet. It answers how many it wrote. A run read from the log is history and emits nothing. +func (u *Upgrades) ImportLog() (int, error) { + keys, err := u.Runs.Keys() + if err != nil { + return 0, fmt.Errorf("listing runs: %w", err) + } + for _, k := range keys { + if strings.HasPrefix(k, u.Node+".") { + return 0, nil + } + } + text, err := u.Machine.ReadFile(PacmanLog) + if err != nil { + return 0, fmt.Errorf("reading %s: %w", PacmanLog, err) + } + n := 0 + for _, r := range PastUpgrades(u.Node, string(text)) { + if _, err := u.Runs.Create(NodeKey(u.Node, r.Run), r); err != nil { + if errors.Is(err, stdio.ErrChanged) { + continue // two upgrades started in the same second: the first is kept + } + return n, fmt.Errorf("writing run %s: %w", r.Run, err) + } + n++ + } + return n, nil +} + +// runTime is when a run began, from its id. +func runTime(id string) (time.Time, bool) { + t, err := time.Parse("20060102T150405Z", id) + return t, err == nil +} + +// RunSummary is one run as pacman_upgrades lists it. +type RunSummary struct { + Node string `json:"node"` + Run string `json:"run"` + Kind string `json:"kind"` + From string `json:"from"` + Round string `json:"round,omitempty"` + Began time.Time `json:"began"` + Outcome string `json:"outcome"` + Ended *time.Time `json:"ended,omitempty"` + Error string `json:"error,omitempty"` + Held int `json:"held_kernels,omitempty"` + Checked string `json:"check,omitempty"` +} + +// summary is where a run stands: its outcome is its last step that ends it (refused, finished, failed), else +// running. +func summary(r Run) RunSummary { + s := RunSummary{Node: r.Node, Run: r.Run, Kind: r.Kind, From: r.From, Round: r.Round, Outcome: "running"} + if t, ok := runTime(r.Run); ok { + s.Began = t + } + for _, st := range r.Steps { + switch st.Step { + case StepRefused, StepFinished, StepFailed: + at := st.At + s.Outcome, s.Ended = st.Step, &at + s.Error = st.Error + if st.Step == StepRefused { + s.Error = st.Why + } + case StepCheckedBefore: + s.Held = len(st.Held) + case StepCheckedAfter: + s.Checked = "failed" + if st.Check != nil && st.Check.Passed { + s.Checked = "passed" + } + } + } + return s +} + +// ListUpgrades answers pacman_upgrades from `runs`: one run whole, or the runs of every node or one, between +// two days, newest first, at most limit of them. +func ListUpgrades(runs Bucket, node, from, until, run string, limit int) (map[string]any, error) { + if run != "" { + if node == "" { + return nil, fmt.Errorf("a run is named with its node") + } + e, err := runs.Get(NodeKey(node, run)) + if err != nil { + return nil, err + } + if e == nil { + return nil, fmt.Errorf("no run %s of %s", run, node) + } + var r Run + if err := jsonValue(e.Value, &r); err != nil { + return nil, err + } + return map[string]any{"run": r, "summary": summary(r)}, nil + } + day := func(s string, end bool) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + t, err := time.ParseInLocation("2006-01-02", s, time.Local) + if err != nil { + return t, fmt.Errorf("%q is not a day as YYYY-MM-DD", s) + } + if end { + t = t.AddDate(0, 0, 1) + } + return t, nil + } + since, err := day(from, false) + if err != nil { + return nil, err + } + before, err := day(until, true) + if err != nil { + return nil, err + } + keys, err := runs.Keys() + if err != nil { + return nil, err + } + var listed []RunSummary + for _, k := range keys { + n, id, ok := strings.Cut(k, ".") + if !ok || node != "" && n != node { + continue + } + t, ok := runTime(id) + if !ok || t.Before(since) || !before.IsZero() && !t.Before(before) { + continue + } + e, err := runs.Get(k) + if err != nil { + return nil, err + } + if e == nil { + continue + } + var r Run + if err := jsonValue(e.Value, &r); err != nil { + return nil, fmt.Errorf("run %s: %w", k, err) + } + listed = append(listed, summary(r)) + } + sort.Slice(listed, func(i, j int) bool { + if !listed[i].Began.Equal(listed[j].Began) { + return listed[i].Began.After(listed[j].Began) + } + return listed[i].Node < listed[j].Node + }) + truncated := len(listed) > limit + if truncated { + listed = listed[:limit] + } + if listed == nil { + listed = []RunSummary{} + } + return map[string]any{"count": len(listed), "runs": listed, "truncated": truncated}, nil +} + +// jsonValue reads a state's value into its shape. +func jsonValue(raw []byte, v any) error { return json.Unmarshal(raw, v) } + +// upgradeState is a state of this module as the tools bundle reads it; a test replaces it. +var upgradeState = func(name string) Bucket { return stdio.State(name) } diff --git a/modules/pacman/cmd/pacman-tools/view.go b/modules/pacman/cmd/pacman-tools/view.go new file mode 100644 index 00000000..1d7918b5 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/view.go @@ -0,0 +1,377 @@ +package main + +// The package view (novox/hq ADR 0507 §6, §7; acceptance 1840/AC6): verbs that read the module's states and the +// controller's `declared-packages`, so any instance answers for every node and no machine is asked. A node that is +// off keeps its last values, shown with the time they describe; a node whose packages are over the bus's cap is +// marked not current. A foreign package is listed apart in every answer. + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// DeclaredPackagesState is the controller's state of the packages each node's modules declare (ADR 0507 §5), +// read through the manifest's `reads`. +const DeclaredPackagesState = "mesh-controller.declared-packages" + +// declaredValue is a node's value of `declared-packages`, as the controller writes it. +type declaredValue struct { + Node string `json:"node"` + Packages []struct { + Package string `json:"package"` + Modules []string `json:"modules"` + Absent bool `json:"absent"` + } `json:"packages"` +} + +// nodeInstalled is one node's key of `installed` with its not-current mark. +type nodeInstalled struct { + Node string + Value InstalledValue + NotCurrent *NotCurrent +} + +func readValue(r StateReader, key string, v any) (bool, error) { + e, err := r.Get(key) + if err != nil || e == nil { + return false, err + } + if err := json.Unmarshal(e.Value, v); err != nil { + return false, fmt.Errorf("%s is not readable: %w", key, err) + } + return true, nil +} + +// nodeKeys are the node keys of a state (``), sorted, and the marks (`.not-current`) apart. +func nodeKeys(r StateReader) (nodes []string, marks map[string]bool, err error) { + keys, err := r.Keys() + if err != nil { + return nil, nil, err + } + marks = map[string]bool{} + for _, k := range keys { + if node, ok := strings.CutSuffix(k, ".not-current"); ok { + marks[node] = true + continue + } + if !strings.Contains(k, ".") { + nodes = append(nodes, k) + } + } + sort.Strings(nodes) + return nodes, marks, nil +} + +func (m *Machine) states() error { + if m.States == nil { + return fmt.Errorf("the module's states cannot be read here") + } + return nil +} + +// installedOn is the installed packages of one node, or of every node when node is empty. +func (m *Machine) installedOn(node string) ([]nodeInstalled, error) { + if err := m.states(); err != nil { + return nil, err + } + r := m.States(StateInstalled) + nodes, marks, err := nodeKeys(r) + if err != nil { + return nil, err + } + if node != "" { + nodes = []string{node} + } + var out []nodeInstalled + for _, n := range nodes { + ni := nodeInstalled{Node: n} + found, err := readValue(r, n, &ni.Value) + if err != nil { + return nil, err + } + if !found { + if node != "" { + return nil, fmt.Errorf("no packages of %s are on the bus: its keeper has not written them", node) + } + continue + } + if marks[n] || node != "" { + var nc NotCurrent + if ok, err := readValue(r, NotCurrentKey(n), &nc); err != nil { + return nil, err + } else if ok { + ni.NotCurrent = &nc + } + } + out = append(out, ni) + } + return out, nil +} + +// declaredOn is what each node's modules declare present, by node and package, with the declaring modules; a +// node the controller holds nothing for is missing. +func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, error) { + r := m.States(DeclaredPackagesState) + out := map[string]map[string][]string{} + for _, n := range nodes { + var v declaredValue + found, err := readValue(r, n, &v) + if err != nil { + return nil, err + } + if !found { + continue + } + out[n] = map[string][]string{} + for _, p := range v.Packages { + if !p.Absent { + out[n][p.Package] = p.Modules + } + } + } + return out, nil +} + +// NodeView is one node in the view. +type NodeView struct { + Node string `json:"node"` + // At is the time of the transaction that left the node's packages as shown. + At time.Time `json:"at"` + Packages int `json:"packages"` + Explicit int `json:"explicit"` + // Current is false while the node's packages are over the bus's cap: what is shown is older. + Current bool `json:"current"` + NotCurrent *NotCurrent `json:"not_current,omitempty"` + // Undeclared is the packages installed explicitly from a repository that no module assigned to the node + // declares; null when the controller holds no declaration of the node. + Undeclared []PackageVersion `json:"undeclared"` + Foreign []PackageVersion `json:"foreign"` +} + +// Spread is one package across the nodes: its version on each node that has it. +type Spread struct { + Name string `json:"name"` + Versions map[string]string `json:"versions"` +} + +// PackageOnNode is one package on one node. +type PackageOnNode struct { + Node string `json:"node"` + Installed bool `json:"installed"` + Version string `json:"version,omitempty"` + Explicit bool `json:"explicit,omitempty"` + Repository string `json:"repository,omitempty"` + Foreign bool `json:"foreign,omitempty"` + DeclaredBy []string `json:"declared_by"` + Current bool `json:"current"` +} + +// Packages is pacman_packages: one package on every node, or the whole view: per node its counts, its explicit +// undeclared packages and its foreign ones; the packages whose versions differ between nodes, and the packages +// on some nodes only, among the packages from a repository (each node's foreign ones are in its own list). +func (m *Machine) Packages(name string, limit int) (map[string]any, error) { + all, err := m.installedOn("") + if err != nil { + return nil, err + } + nodes := make([]string, 0, len(all)) + for _, ni := range all { + nodes = append(nodes, ni.Node) + } + declared, err := m.declaredOn(nodes) + if err != nil { + return nil, err + } + if name != "" { + on := []PackageOnNode{} + for _, ni := range all { + p := PackageOnNode{Node: ni.Node, Current: ni.NotCurrent == nil, DeclaredBy: declared[ni.Node][name]} + if p.DeclaredBy == nil { + p.DeclaredBy = []string{} + } + for _, ip := range ni.Value.Packages { + if ip.Name == name { + p.Installed, p.Version, p.Explicit = true, ip.Version, ip.Explicit + p.Foreign = ip.Repo == RepoForeign + if !p.Foreign { + p.Repository = ip.Repo + } + } + } + on = append(on, p) + } + return map[string]any{"package": name, "nodes": on}, nil + } + views := []NodeView{} + versions := map[string]map[string]string{} + for _, ni := range all { + v := NodeView{Node: ni.Node, At: ni.Value.At, Packages: len(ni.Value.Packages), Current: ni.NotCurrent == nil, + NotCurrent: ni.NotCurrent, Foreign: []PackageVersion{}} + d, known := declared[ni.Node] + if known { + v.Undeclared = []PackageVersion{} + } + for _, p := range ni.Value.Packages { + if p.Explicit { + v.Explicit++ + } + if p.Repo == RepoForeign { + v.Foreign = append(v.Foreign, PackageVersion{p.Name, p.Version}) + continue + } + if versions[p.Name] == nil { + versions[p.Name] = map[string]string{} + } + versions[p.Name][ni.Node] = p.Version + if known && p.Explicit && d[p.Name] == nil { + v.Undeclared = append(v.Undeclared, PackageVersion{p.Name, p.Version}) + } + } + views = append(views, v) + } + names := make([]string, 0, len(versions)) + for n := range versions { + names = append(names, n) + } + sort.Strings(names) + differing, some := []Spread{}, []Spread{} + for _, n := range names { + vs := versions[n] + if len(vs) < len(all) { + some = append(some, Spread{n, vs}) + } + seen := map[string]bool{} + for _, ver := range vs { + seen[ver] = true + } + if len(seen) > 1 { + differing = append(differing, Spread{n, vs}) + } + } + out := map[string]any{"nodes": views} + for key, list := range map[string][]Spread{"differing": differing, "some_nodes": some} { + b := bound(key, list, limit) + out[key] = b[key] + out[key+"_count"] = b["count"] + out[key+"_truncated"] = b["truncated"] + } + return out, nil +} + +// UpdatesView is one node's `updates` with the time its key last changed. +type UpdatesView struct { + Node string `json:"node"` + Reachable bool `json:"reachable"` + Count int `json:"count"` + Packages []VersionChange `json:"packages"` + Changed *time.Time `json:"changed,omitempty"` +} + +// UpdatesFor is pacman_updates: what a full upgrade would change, on one node or on every node, each with when its +// key last changed (the bus's time of the write; the value holds no time of a read, ADR 0289 §4). +func (m *Machine) UpdatesFor(node string) (map[string]any, error) { + if err := m.states(); err != nil { + return nil, err + } + r := m.States(StateUpdates) + nodes := []string{node} + if node == "" { + var err error + if nodes, _, err = nodeKeys(r); err != nil { + return nil, err + } + } + views := []UpdatesView{} + for _, n := range nodes { + var v UpdatesValue + found, err := readValue(r, n, &v) + if err != nil { + return nil, err + } + if !found { + if node != "" { + return nil, fmt.Errorf("no updates of %s are on the bus: its keeper has not written them", node) + } + continue + } + uv := UpdatesView{Node: n, Reachable: v.Reachable, Count: len(v.Packages), Packages: v.Packages} + if uv.Packages == nil { + uv.Packages = []VersionChange{} + } + if h, err := r.History(n); err == nil && len(h) > 0 { + at := h[len(h)-1].At + uv.Changed = &at + } + views = append(views, uv) + } + return map[string]any{"nodes": views}, nil +} + +// InstalledFrom is pacman_installed from the bus: a node's installed packages with version, reason, repository +// and whether foreign, narrowed by name, reason or foreign, with totals, the time they describe and whether +// they are current. +func (m *Machine) InstalledFrom(node, match, reason string, foreignOnly bool, limit int) (map[string]any, error) { + ni, err := m.oneNode(node) + if err != nil { + return nil, err + } + pkgs := []Package{} + counts := map[string]int{"explicit": 0, "dependency": 0, "foreign": 0} + for _, ip := range ni.Value.Packages { + p := Package{Name: ip.Name, Version: ip.Version, Reason: "dependency", Foreign: ip.Repo == RepoForeign} + if ip.Explicit { + p.Reason = "explicit" + } + if !p.Foreign { + p.Repository = ip.Repo + } + counts[p.Reason]++ + if p.Foreign { + counts["foreign"]++ + } + if match != "" && !strings.Contains(p.Name, match) || reason != "" && p.Reason != reason || foreignOnly && !p.Foreign { + continue + } + pkgs = append(pkgs, p) + } + out := bound("packages", pkgs, limit) + out["totals"] = counts + out["node"], out["at"], out["current"] = ni.Node, ni.Value.At, ni.NotCurrent == nil + if ni.NotCurrent != nil { + out["not_current"] = ni.NotCurrent + } + return out, nil +} + +// ForeignFrom is pacman_foreign from the bus: a node's packages from outside the repositories. +func (m *Machine) ForeignFrom(node string) (map[string]any, error) { + ni, err := m.oneNode(node) + if err != nil { + return nil, err + } + pkgs := []PackageVersion{} + for _, ip := range ni.Value.Packages { + if ip.Repo == RepoForeign { + pkgs = append(pkgs, PackageVersion{ip.Name, ip.Version}) + } + } + return map[string]any{"node": ni.Node, "count": len(pkgs), "packages": pkgs, "at": ni.Value.At, "current": ni.NotCurrent == nil}, nil +} + +// oneNode is the named node's packages, this node's when none is named. +func (m *Machine) oneNode(node string) (nodeInstalled, error) { + if node == "" { + node = m.Node + } + if node == "" { + return nodeInstalled{}, fmt.Errorf("node is required: this bundle does not know its own") + } + all, err := m.installedOn(node) + if err != nil { + return nodeInstalled{}, err + } + return all[0], nil +} diff --git a/modules/pacman/cmd/pacman-tools/view_test.go b/modules/pacman/cmd/pacman-tools/view_test.go new file mode 100644 index 00000000..8a823c55 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/view_test.go @@ -0,0 +1,160 @@ +package main + +// The package view over the bus (novox/hq ADR 0507 §6, §7; acceptance 1840/AC6): two nodes' `installed` keys and +// their `declared-packages`, answered without asking any machine. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +// viewStates is two nodes' packages on the bus: node-a current and declared, node-b over the cap since a while, +// with a foreign package, and no declaration the controller holds. +func viewStates(t *testing.T) (*fakeStates, *Machine) { + t.Helper() + states := &fakeStates{} + inst := states.store(StateInstalled) + inst.Put("node-a", InstalledValue{At: t0, Packages: []InstalledPackage{ + {"glibc", "2.42-1", false, "core"}, {"tmux", "3.5-1", true, "extra"}, {"vim", "9.1-1", true, "extra"}, + {"zsh", "5.9-2", true, "extra"}, + }}) + inst.Put("node-b", InstalledValue{At: t0.Add(-time.Hour), Packages: []InstalledPackage{ + {"glibc", "2.42-1", false, "core"}, {"yay", "12.0-1", true, RepoForeign}, {"zsh", "5.9-1", false, "extra"}, + }}) + inst.Put("node-b.not-current", NotCurrent{Bytes: 300000, Since: t0.Add(-30 * time.Minute)}) + decl := states.store(DeclaredPackagesState) + var d declaredValue + json.Unmarshal([]byte(`{"node":"node-a","packages":[{"package":"vim","modules":["editor"],"absent":false},`+ + `{"package":"tmux","modules":["shell"],"absent":true},{"package":"zsh","modules":["login","shell"],"absent":false}]}`), &d) + decl.Put("node-a", d) + m := machine(func(_ context.Context, name string, args ...string) Ran { + t.Errorf("a machine was asked: %s %v", name, args) + return Ran{Status: 99} + }, 1000) + m.Node, m.States = "node-a", readerOf(states) + return states, m +} + +func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) { + _, m := viewStates(t) + r, err := m.Packages("zsh", 500) + if err != nil { + t.Fatal(err) + } + on := r["nodes"].([]PackageOnNode) + if len(on) != 2 { + t.Fatalf("%+v", on) + } + a, b := on[0], on[1] + if a.Node != "node-a" || !a.Installed || a.Version != "5.9-2" || !a.Explicit || a.Repository != "extra" || + strings.Join(a.DeclaredBy, ",") != "login,shell" || !a.Current { + t.Errorf("node-a: %+v", a) + } + if b.Node != "node-b" || b.Version != "5.9-1" || b.Explicit || len(b.DeclaredBy) != 0 || b.Current { + t.Errorf("node-b: %+v", b) + } + r, _ = m.Packages("yay", 500) + on = r["nodes"].([]PackageOnNode) + if on[0].Installed || !on[1].Installed || !on[1].Foreign || on[1].Repository != "" { + t.Errorf("yay: %+v", on) + } +} + +func TestTheViewSaysWhatDiffersWhatIsOnSomeNodesWhatNoModuleDeclaresAndTheForeignApart(t *testing.T) { + _, m := viewStates(t) + r, err := m.Packages("", 500) + if err != nil { + t.Fatal(err) + } + nodes := r["nodes"].([]NodeView) + if len(nodes) != 2 { + t.Fatalf("%+v", nodes) + } + a, b := nodes[0], nodes[1] + if a.Node != "node-a" || a.Packages != 4 || a.Explicit != 3 || !a.Current || fmt.Sprint(a.Undeclared) != "[{tmux 3.5-1}]" || + len(a.Foreign) != 0 || !a.At.Equal(t0) { + t.Errorf("node-a: %+v", a) + } + if b.Current || b.NotCurrent == nil || b.NotCurrent.Bytes != 300000 || b.Undeclared != nil || + fmt.Sprint(b.Foreign) != "[{yay 12.0-1}]" || !b.At.Equal(t0.Add(-time.Hour)) { + t.Errorf("node-b (not current, no declaration, a foreign package): %+v", b) + } + if d := r["differing"].([]Spread); len(d) != 1 || d[0].Name != "zsh" || d[0].Versions["node-a"] != "5.9-2" || d[0].Versions["node-b"] != "5.9-1" { + t.Errorf("differing: %+v", d) + } + some := r["some_nodes"].([]Spread) + var names []string + for _, s := range some { + names = append(names, s.Name) + } + if strings.Join(names, ",") != "tmux,vim" { + t.Errorf("on some nodes only, the foreign apart: %v", names) + } + r, _ = m.Packages("", 1) + if r["some_nodes_count"] != 2 || r["some_nodes_truncated"] != true || len(r["some_nodes"].([]Spread)) != 1 { + t.Errorf("bounded: %v", r) + } +} + +func TestInstalledAndForeignAnswerFromTheBus(t *testing.T) { + _, m := viewStates(t) + r, err := m.InstalledFrom("", "", "", false, 10) + if err != nil || r["node"] != "node-a" || r["count"] != 4 || r["current"] != true { + t.Fatalf("this node's: %v %v", r, err) + } + pk := r["packages"].([]Package) + if pk[0].Reason != "dependency" || pk[0].Repository != "core" || pk[1].Reason != "explicit" { + t.Errorf("%+v", pk) + } + r, err = m.InstalledFrom("node-b", "", "", true, 10) + if err != nil || r["count"] != 1 || r["current"] != false || r["not_current"] == nil { + t.Fatalf("node-b's foreign: %v %v", r, err) + } + if tot := r["totals"].(map[string]int); tot["explicit"] != 1 || tot["dependency"] != 2 || tot["foreign"] != 1 { + t.Errorf("totals: %v", tot) + } + f, err := m.ForeignFrom("node-b") + if err != nil || f["count"] != 1 || fmt.Sprint(f["packages"]) != "[{yay 12.0-1}]" { + t.Errorf("%v %v", f, err) + } + if _, err := m.InstalledFrom("node-c", "", "", false, 10); err == nil || !strings.Contains(err.Error(), "node-c") { + t.Errorf("a node with no packages on the bus: %v", err) + } +} + +func TestUpdatesAnswerEveryNodeAndWhenItsListLastChanged(t *testing.T) { + states, m := viewStates(t) + up := states.store(StateUpdates) + up.now = func() time.Time { return t0.Add(-2 * time.Hour) } + up.Put("node-a", UpdatesValue{Reachable: true, Packages: []VersionChange{{"zsh", "5.9-2", "5.9-3"}}}) + up.now = func() time.Time { return t0.Add(-3 * time.Hour) } + up.Put("node-b", UpdatesValue{Reachable: false, Packages: []VersionChange{}}) + r, err := m.UpdatesFor("") + if err != nil { + t.Fatal(err) + } + v := r["nodes"].([]UpdatesView) + if len(v) != 2 || v[0].Count != 1 || !v[0].Reachable || v[0].Changed == nil || !v[0].Changed.Equal(t0.Add(-2*time.Hour)) || + v[1].Reachable || !v[1].Changed.Equal(t0.Add(-3*time.Hour)) { + t.Fatalf("%+v", v) + } + r, _ = m.UpdatesFor("node-b") + if v := r["nodes"].([]UpdatesView); len(v) != 1 || v[0].Node != "node-b" { + t.Errorf("one node: %+v", v) + } +} + +func TestANodeArgumentIsOneKeyToken(t *testing.T) { + for _, bad := range []string{"node-a.not-current", "node-a.*", "a b"} { + if _, err := nodeOf(map[string]any{"node": bad}); err == nil { + t.Errorf("%q was taken as a node", bad) + } + } + if n, err := nodeOf(map[string]any{"node": "node-a"}); err != nil || n != "node-a" { + t.Errorf("%q %v", n, err) + } +} diff --git a/modules/pacman/cmd/pacman-tools/watch.go b/modules/pacman/cmd/pacman-tools/watch.go new file mode 100644 index 00000000..e66f69ae --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/watch.go @@ -0,0 +1,129 @@ +package main + +// The machine's watch (novox/hq ADR 0507 §2, §4): inotify on the directory of pacman's database lock, for the +// lock file deleted (a transaction ended), and on the mirror list's directory, for the list written or moved +// into place (reflector rewrote it). A directory is watched, not the file: the lock comes and goes, and a list +// replaced by a rename is a new file. Nothing is polled. + +import ( + "errors" + "fmt" + "os" + "sync" + "unsafe" + + "golang.org/x/sys/unix" +) + +type inotify struct { + f *os.File + lockWD int + mirWD int + lock string + mirrors string + out chan Signal + // pending holds a signal of each kind until the keeper takes it, so a burst of one kind is one signal and + // never crowds out the other. + mu sync.Mutex + pending map[Signal]bool + notify chan struct{} +} + +// WatchMachine watches lockDir for lockName deleted and mirrorDir for mirrorName written or moved in. +func WatchMachine(lockDir, lockName, mirrorDir, mirrorName string) (Watcher, error) { + fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) + if err != nil { + return nil, fmt.Errorf("inotify: %w", err) + } + w := &inotify{lock: lockName, mirrors: mirrorName, out: make(chan Signal), pending: map[Signal]bool{}, + notify: make(chan struct{}, 1)} + if w.lockWD, err = unix.InotifyAddWatch(fd, lockDir, unix.IN_DELETE|unix.IN_MOVED_FROM); err != nil { + unix.Close(fd) + return nil, fmt.Errorf("watching %s: %w", lockDir, err) + } + if w.mirWD, err = unix.InotifyAddWatch(fd, mirrorDir, unix.IN_CLOSE_WRITE|unix.IN_MOVED_TO); err != nil { + unix.Close(fd) + return nil, fmt.Errorf("watching %s: %w", mirrorDir, err) + } + // a non-blocking descriptor as an os.File is read through the runtime's poller, so Close ends a read. + w.f = os.NewFile(uintptr(fd), "inotify") + go w.read() + go w.forward() + return w, nil +} + +func (w *inotify) Signals() <-chan Signal { return w.out } + +func (w *inotify) Close() error { return w.f.Close() } + +func (w *inotify) read() { + defer close(w.notify) + buf := make([]byte, 64*(unix.SizeofInotifyEvent+unix.NAME_MAX+1)) + for { + n, err := w.f.Read(buf) + if err != nil { + if !errors.Is(err, os.ErrClosed) { + fmt.Fprintf(os.Stderr, "[pacman] keeper: the machine's watch failed: %v\n", err) + } + return + } + for off := 0; off+unix.SizeofInotifyEvent <= n; { + ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off])) + nameAt := off + unix.SizeofInotifyEvent + end := nameAt + int(ev.Len) + if end > n { + break + } + name := cstring(buf[nameAt:end]) + off = end + if ev.Mask&unix.IN_Q_OVERFLOW != 0 { + // events were lost: anything may have happened, so both are read again. + w.send(LockReleased) + w.send(MirrorsRewritten) + continue + } + switch { + case int(ev.Wd) == w.lockWD && name == w.lock: + w.send(LockReleased) + case int(ev.Wd) == w.mirWD && name == w.mirrors: + w.send(MirrorsRewritten) + } + } + } +} + +// send marks a signal pending; one of the same kind already waiting covers it. +func (w *inotify) send(s Signal) { + w.mu.Lock() + w.pending[s] = true + w.mu.Unlock() + select { + case w.notify <- struct{}{}: + default: + } +} + +// forward hands the pending signals to the keeper, in a fixed order, until the watch ends. +func (w *inotify) forward() { + defer close(w.out) + for range w.notify { + for _, s := range []Signal{LockReleased, MirrorsRewritten} { + w.mu.Lock() + due := w.pending[s] + delete(w.pending, s) + w.mu.Unlock() + if due { + w.out <- s + } + } + } +} + +func cstring(b []byte) string { + for i, c := range b { + if c == 0 { + return string(b[:i]) + } + } + return string(b) +} diff --git a/modules/pacman/files/keeper.json b/modules/pacman/files/keeper.json new file mode 100644 index 00000000..8e340a94 --- /dev/null +++ b/modules/pacman/files/keeper.json @@ -0,0 +1,5 @@ +{ + "upgrade-order": [], + "record-days": 180, + "round-start-minutes": 120 +} diff --git a/modules/pacman/files/mesh-pacman-weekly-upgrade.service b/modules/pacman/files/mesh-pacman-weekly-upgrade.service deleted file mode 100644 index 69741dd7..00000000 --- a/modules/pacman/files/mesh-pacman-weekly-upgrade.service +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by the mesh. Do not edit — module pacman writes this file and replaces it at every push. -[Unit] -Description=Weekly full upgrade (the mesh's pacman module, ADR 0477) -Wants=network-online.target -After=network-online.target - -[Service] -# The upgrade pacman_upgrade runs (pacman -Syu --noconfirm), as a oneshot whose result is the module's health -# (pacman_upgrade_health). The mesh restarts no machine for an upgrade: a new kernel waits for the next restart. -Type=oneshot -Nice=10 -ExecStart=/usr/bin/pacman -Syu --noconfirm diff --git a/modules/pacman/files/mesh-pacman-weekly-upgrade.timer b/modules/pacman/files/mesh-pacman-weekly-upgrade.timer deleted file mode 100644 index a8a9b4d9..00000000 --- a/modules/pacman/files/mesh-pacman-weekly-upgrade.timer +++ /dev/null @@ -1,11 +0,0 @@ -# Generated by the mesh. Do not edit — module pacman writes this file and replaces it at every push. -[Unit] -Description=Weekly full upgrade, Saturday 04:00 local time on every node (the mesh's pacman module, ADR 0477) - -[Timer] -# The same moment on every node: no random delay. A node that was off or asleep then upgrades at its next start. -OnCalendar=Sat *-*-* 04:00:00 -Persistent=true - -[Install] -WantedBy=timers.target diff --git a/modules/pacman/go.mod b/modules/pacman/go.mod index a88b95d8..8d722f8f 100644 --- a/modules/pacman/go.mod +++ b/modules/pacman/go.mod @@ -1,5 +1,25 @@ module pacman -go 1.22 +go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.6 +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 + github.com/nats-io/nats-server/v2 v2.11.17 +) + +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/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + golang.org/x/time v0.15.0 // indirect +) + +require ( + github.com/klauspost/compress v1.20.0 // indirect + github.com/nats-io/nats.go v1.54.0 + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 +) diff --git a/modules/pacman/go.sum b/modules/pacman/go.sum index 0dd60617..db1ea18c 100644 --- a/modules/pacman/go.sum +++ b/modules/pacman/go.sum @@ -1,2 +1,27 @@ -git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= -git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +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.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +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.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +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.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +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/pacman/module.json b/modules/pacman/module.json index ca1a81a3..97eaad3f 100644 --- a/modules/pacman/module.json +++ b/modules/pacman/module.json @@ -11,15 +11,73 @@ "scope": "node" } ], + "reads": [ + "mesh-controller.declared-packages" + ], + "uses": [ + "operator-channel" + ], + "config": { + "upgrade-order": { + "type": "list", + "description": "The nodes in the order they upgrade, each by its name: the first opens each week's round on Saturday at 04:00 local time, and each node upgrades after the one before it passed its after-upgrade check (novox/hq ADR 0508 §5). Empty: no round starts, and every node's health says why. Falls back to the mesh-level key of the same name.", + "trusted": true, + "predefined": "upgrade-order", + "default": [] + }, + "record-days": { + "type": "number", + "description": "How many days a package record taken before an upgrade is kept on the bus before it is deleted (novox/hq ADR 0508 §2); a rollback can return only to a record still kept.", + "minimum": 1, + "trusted": false, + "default": 180 + }, + "round-start-minutes": { + "type": "number", + "description": "How many minutes a node whose turn in the round came may take to start its upgrade before the round is said to stand still, in that node's and the waiting nodes' health (novox/hq ADR 0508 §6).", + "minimum": 1, + "trusted": false, + "default": 120 + } + }, + "own-secrets": { + "broker-keeper": { + "path": "${dir:state}/broker-keeper.secret", + "owner": "pacman-keeper" + } + }, + "state": [ + "installed", + "updates", + "runs", + "records", + "rounds", + "asks" + ], + "emits": [ + "package.changed", + "upgrade.recorded", + "upgrade.kernel-held", + "upgrade.started", + "upgrade.finished", + "upgrade.failed", + "upgrade.checked", + "rollback.started", + "rollback.finished" + ], "tools": [ "pacman_search", "pacman_info", "pacman_installed", + "pacman_packages", "pacman_owns", "pacman_files", "pacman_updates", "pacman_upgrade", "pacman_upgrade_health", + "pacman_upgrades", + "pacman_rollback", + "pacman_round", "pacman_orphans", "pacman_remove_orphans", "pacman_cache", @@ -80,29 +138,89 @@ } }, { - "id": "upgrade-service", - "type": "file", - "path": "/etc/systemd/system/mesh-pacman-weekly-upgrade.service", - "mode": "0644", - "template": "files/mesh-pacman-weekly-upgrade.service" + "id": "keeper-account", + "type": "user", + "name": "pacman-keeper", + "shell": "/usr/bin/nologin" }, { - "id": "upgrade-timer", - "type": "file", - "path": "/etc/systemd/system/mesh-pacman-weekly-upgrade.timer", - "mode": "0644", - "template": "files/mesh-pacman-weekly-upgrade.timer" + "id": "state", + "type": "directory", + "mode": "0711", + "place": "mesh", + "unknown": "program" }, { - "id": "weekly-upgrade", - "type": "service", - "unit": "mesh-pacman-weekly-upgrade.timer", - "state": "running", - "boot": "enabled", - "restart-on": [ - "upgrade-service", - "upgrade-timer" + "id": "keeper-config", + "type": "file", + "path": "${dir:state}/keeper.json", + "mode": "0644", + "takes-config": [ + "upgrade-order", + "record-days", + "round-start-minutes" ], + "merge": "json", + "template": "files/keeper.json", + "trusted": true + }, + { + "id": "root-helper", + "type": "process", + "name": "pacman-root", + "artifact": "tools", + "run": [ + "./pacman-tools", + "root" + ], + "env": { + "PACMAN_ROOT_SOCKET": "${dir:state}/root/helper.sock", + "PACMAN_ROOT_PEER": "pacman-keeper" + }, + "health": { + "kind": "unit" + } + }, + { + "id": "keeper", + "type": "process", + "name": "pacman-keeper", + "artifact": "tools", + "run": [ + "./pacman-tools", + "keeper" + ], + "user": "pacman-keeper", + "env": { + "PACMAN_KEEPER_BROKER": "${dir:state}/broker-keeper.secret", + "PACMAN_KEEPER_CONFIG": "${dir:state}/keeper.json", + "PACMAN_ROOT_SOCKET": "${dir:state}/root/helper.sock" + }, + "restart-on": [ + "keeper-config" + ], + "bus": { + "state": [ + "installed", + "updates", + "runs", + "records", + "rounds", + "asks" + ], + "emits": [ + "package.changed", + "upgrade.recorded", + "upgrade.kernel-held", + "upgrade.started", + "upgrade.finished", + "upgrade.failed", + "upgrade.checked", + "rollback.started", + "rollback.finished" + ], + "asks": true + }, "health": { "kind": "tool", "tool": "pacman_upgrade_health", @@ -124,7 +242,10 @@ "binary": "pacman-tools", "loads": [ "pacman-tools" - ] + ], + "env": { + "PACMAN_KEEPER_CONFIG": "${dir:state}/keeper.json" + } }, { "name": "files", diff --git a/modules/postgres/README.md b/modules/postgres/README.md index be777275..60f0468f 100644 --- a/modules/postgres/README.md +++ b/modules/postgres/README.md @@ -119,7 +119,8 @@ what is declared, refusing secrets, taking back what is no longer declared, and Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416). -- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). +- `${dir:data}` (`/var/lib/mesh/`): everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). +- `${dir:state}`: the package's own directory, declared for one delivery with the package's owner and mode so the node-engine gives it back (issue 1873); the module keeps nothing there. - `${dir:grants}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). - `/var/lib/mesh-store`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). - `${dir:store-data}/dumps`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). diff --git a/modules/postgres/cmd/postgres-provider/manifest_test.go b/modules/postgres/cmd/postgres-provider/manifest_test.go new file mode 100644 index 00000000..c4a29d58 --- /dev/null +++ b/modules/postgres/cmd/postgres-provider/manifest_test.go @@ -0,0 +1,94 @@ +package main + +// The module's directories (novox/hq issue 1873): its state directory said place ".", which is /var/lib/postgres, +// the postgresql package's own (tmpfiles.d/postgresql.conf: postgres, 0700), and its grants directory, placed by +// default, lay inside it; the node-engine gave /var/lib/postgres to the operator's account. What the module keeps +// moves to the mesh's directory for it, and for one delivery the record "state" states the package's owner and mode, +// so the node-engine, which takes a directory it recorded at the same path under the same id as its own, gives it +// back. The store's data is not touched: it is /var/lib/mesh-store, as before. + +import ( + "encoding/json" + "os" + "reflect" + "strings" + "testing" +) + +func postgresManifest(t *testing.T) (map[string]map[string]any, map[string]any) { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "${dir:state}") { + t.Errorf("the manifest still keeps something in ${dir:state}, which is /var/lib/postgres") + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + out := map[string]map[string]any{} + list, _ := m["resources"].([]any) + for _, x := range list { + r, _ := x.(map[string]any) + out[r["id"].(string)] = r + } + return out, m +} + +// 1873/AC2: secrets, consumers and grants are kept in /mesh/postgres; "state" gives /var/lib/postgres back +// to postgres, 0700; the store's data and dumps are as they were. +func TestTheModuleKeepsItsFilesOutsideThePackagesDirectory(t *testing.T) { + rs, m := postgresManifest(t) + data := rs["data"] + if data == nil || data["type"] != "directory" || data["place"] != "mesh" || data["path"] != nil || data["mode"] != "0700" { + t.Fatalf("the module's directory is placed \"mesh\" with no path, 0700 (/var/lib/mesh/postgres): %v", data) + } + if g := rs["grants"]; g == nil || g["path"] != "${dir:data}/grants" || g["place"] != nil { + t.Errorf("grants lies in the module's directory: %v", g) + } + if c := rs["consumers"]; c == nil || c["path"] != "${dir:data}/consumers.json" { + t.Errorf("consumers.json lies in the module's directory: %v", c) + } + own, _ := m["own-secrets"].(map[string]any) + if own["superuser"] != "${dir:data}/superuser.secret" || own["reader"] != "${dir:data}/reader.secret" { + t.Errorf("the own secrets lie in the module's directory: %v", own) + } + state := rs["state"] + if state == nil || state["type"] != "directory" || state["path"] != "/var/lib/postgres" || state["owner"] != "postgres" || + state["mode"] != "0700" || state["place"] != nil { + t.Errorf("state gives /var/lib/postgres back as postgresql declares it, postgres 0700: %v", state) + } + + // The store's data, its dumps and how the container reads them: exactly as before. + if s := rs["store-data"]; !reflect.DeepEqual(s, map[string]any{"id": "store-data", "type": "directory", + "path": "/var/lib/mesh-store", "mode": "0700", "owner": "999:70", "unknown": "program"}) { + t.Errorf("store-data changed: %v", s) + } + if d := rs["dumps"]; !reflect.DeepEqual(d, map[string]any{"id": "dumps", "type": "directory", + "path": "${dir:store-data}/dumps", "mode": "0700", "owner": "999:70", "unknown": "program"}) { + t.Errorf("dumps changed: %v", d) + } + server := rs["server"] + if !reflect.DeepEqual(server["volumes"], []any{"/var/lib/mesh-store:/var/lib/postgresql/data", + "${dir:data}/superuser.secret:/run/secrets/superuser:ro"}) { + t.Errorf("the container's volumes: %v", server["volumes"]) + } + if env, _ := server["env"].(map[string]any); env["PGDATA"] != "/var/lib/postgresql/data/pgdata" { + t.Errorf("PGDATA changed: %v", env) + } + build, _ := m["build"].(map[string]any) + artifacts, _ := build["artifacts"].([]any) + env := artifacts[0].(map[string]any)["env"].(map[string]any) + for key, want := range map[string]string{ + "MESH_PROVISION_PASSWORD_FILE": "${dir:data}/superuser.secret", + "MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:data}/reader.secret", + "MESH_CONSUMERS_FILE": "${dir:data}/consumers.json", + "MESH_RECEIVES": "${dir:grants}/mesh.json", + } { + if env[key] != want { + t.Errorf("%s is %v, not %s", key, env[key], want) + } + } +} diff --git a/modules/postgres/module.json b/modules/postgres/module.json index 919df421..e8a59069 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -61,8 +61,8 @@ "postgres-database": "${dir:grants}" }, "own-secrets": { - "superuser": "${dir:state}/superuser.secret", - "reader": "${dir:state}/reader.secret" + "superuser": "${dir:data}/superuser.secret", + "reader": "${dir:data}/reader.secret" }, "data": { "own": [ @@ -93,22 +93,31 @@ }, "resources": [ { - "id": "state", + "id": "data", "type": "directory", "mode": "0700", - "place": ".", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/postgres", + "owner": "postgres", + "mode": "0700", "unknown": "program" }, { "id": "grants", "type": "directory", + "path": "${dir:data}/grants", "mode": "0700", "unknown": "program" }, { "id": "consumers", "type": "file", - "path": "${dir:state}/consumers.json", + "path": "${dir:data}/consumers.json", "mode": "0600", "template": "files/consumers.json" }, @@ -146,7 +155,7 @@ ], "volumes": [ "/var/lib/mesh-store:/var/lib/postgresql/data", - "${dir:state}/superuser.secret:/run/secrets/superuser:ro" + "${dir:data}/superuser.secret:/run/secrets/superuser:ro" ] }, { @@ -169,10 +178,10 @@ ], "env": { "MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable", - "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/superuser.secret", + "MESH_PROVISION_PASSWORD_FILE": "${dir:data}/superuser.secret", "MESH_RECEIVES": "${dir:grants}/mesh.json", - "MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret", - "MESH_CONSUMERS_FILE": "${dir:state}/consumers.json" + "MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:data}/reader.secret", + "MESH_CONSUMERS_FILE": "${dir:data}/consumers.json" } }, { diff --git a/modules/restic/README.md b/modules/restic/README.md index 1ec851b8..c415262e 100644 --- a/modules/restic/README.md +++ b/modules/restic/README.md @@ -18,6 +18,12 @@ Against mistakes, not disasters: nothing leaves the machine. kept for good. That is a choice: deleting a module's last restore point is a person's act, and until then it costs only space. +**A repository another restic holds** (a listing of the restore points, say, while a delivery plan backs up +the machine's modules before their moves) is waited for: the thinning asks restic to retry the lock for up to +ten minutes (`--retry-lock`, novox/hq issue 620), and fails only after that. A failed thinning keeps every +restore point, and the module's health stays unhealthy until a thinning succeeds: the next night's, or one +after `node-backup.now`. + `node-backup.backed-up` lists each module's restore points taken by hand under `handTaken`: when each was taken, the time it is kept at least until, whether it still waits for a later good night, and a sentence saying so. diff --git a/modules/restic/cmd/restic-backups/backups.go b/modules/restic/cmd/restic-backups/backups.go index 4485b9d5..876c73ac 100644 --- a/modules/restic/cmd/restic-backups/backups.go +++ b/modules/restic/cmd/restic-backups/backups.go @@ -406,6 +406,15 @@ func (b *Backups) backUp(ctx context.Context, only string, hand bool) (map[strin return outcome, nil } +// thinningWaitsForALock is how long the thinning waits for a repository another restic holds, with restic's own +// --retry-lock, before it fails as it did before (novox/hq issue 620). The night's lock keeps out this module's own +// backups and restores, not a restic outside it: a listing of the restore points, asked while a delivery plan backs +// up a machine's data modules before their moves (ADR 0233), held the lock 246 ms before a thinning asked for it. +// Ten minutes outlasts such a plan's backups of every data module on a machine (a whole night of fifteen modules +// took under five minutes) and stays well within the half hour after which restic takes a lock as stale; a lock +// held longer than this is not a passing collision, and is said the same night. +const thinningWaitsForALock = 10 * time.Minute + // thin forgets the restore points taken by hand that are past their days and have a later night of their // module, by id, then thins the rest by the counts, keeping every one still tagged as taken by hand (novox/hq // issue 453). It answers the short ids it let go — on an error of the counts' thinning too, for those are gone. A list that does not read thins nothing: a restore point @@ -420,14 +429,14 @@ func (b *Backups) thin(ctx context.Context, keep Retention) ([]string, error) { ids, letGo = append(ids, s.ID), append(letGo, s.ShortID) } if len(ids) > 0 { - if _, err := b.restic(ctx, append([]string{"forget"}, ids...)...); err != nil { + if _, err := b.restic(ctx, append([]string{"forget", "--retry-lock", thinningWaitsForALock.String()}, ids...)...); err != nil { return nil, err } b.Say("let go the restore points taken by hand past %d days with a later night: %s", keep.Hand, strings.Join(letGo, ", ")) } _, err = b.restic(ctx, "forget", "--prune", "--group-by", "host,tags", "--keep-daily", fmt.Sprint(keep.Daily), "--keep-weekly", fmt.Sprint(keep.Weekly), "--keep-monthly", fmt.Sprint(keep.Monthly), - "--keep-tag", HandTag) + "--keep-tag", HandTag, "--retry-lock", thinningWaitsForALock.String()) return letGo, err } diff --git a/modules/restic/cmd/restic-backups/backups_test.go b/modules/restic/cmd/restic-backups/backups_test.go index 24930540..3a545185 100644 --- a/modules/restic/cmd/restic-backups/backups_test.go +++ b/modules/restic/cmd/restic-backups/backups_test.go @@ -108,7 +108,7 @@ func TestANightDumpsBeforeEachSnapshotAndOneFailingModuleFailsOnlyItself(t *test "sh -c fail-it", "restic backup --json --tag module=mail /", "restic snapshots --json", - "restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6 --keep-tag hand", + "restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6 --keep-tag hand --retry-lock 10m0s", } if !reflect.DeepEqual(calls, want) { t.Errorf("ran\n%s\nwant\n%s", strings.Join(calls, "\n"), strings.Join(want, "\n")) diff --git a/modules/restic/cmd/restic-backups/hand_test.go b/modules/restic/cmd/restic-backups/hand_test.go index 22af7160..afd4637b 100644 --- a/modules/restic/cmd/restic-backups/hand_test.go +++ b/modules/restic/cmd/restic-backups/hand_test.go @@ -74,7 +74,7 @@ func TestAHandTakenBackupIsTaggedAndTheThinningKeepsThatTag(t *testing.T) { if got := h.with("restic backup"); len(got) != 2 || got[1] != "restic backup --json --tag module=pg /" { t.Errorf("the night backed up with %q, want it tagged with its module alone", got) } - want := "restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6 --keep-tag hand" + want := "restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6 --keep-tag hand --retry-lock 10m0s" for _, f := range h.with("restic forget") { if f != want { t.Errorf("thinned with %q, want %q", f, want) @@ -134,7 +134,7 @@ func TestTheThinningForgetsAnExpiredHandTakenRestorePointByItsId(t *testing.T) { _, err := b.BackUp(context.Background(), "") must(t, err) forgets := h.with("restic forget") - if len(forgets) != 2 || forgets[0] != "restic forget aaaaaaaa11" || !strings.HasPrefix(forgets[1], "restic forget --prune") { + if len(forgets) != 2 || forgets[0] != "restic forget --retry-lock 10m0s aaaaaaaa11" || !strings.HasPrefix(forgets[1], "restic forget --prune") { t.Fatalf("thinned with %q, want the expired one forgotten by id, then the rotation", forgets) } th := b.LastRun().Thinned diff --git a/modules/restic/cmd/restic-backups/lock_test.go b/modules/restic/cmd/restic-backups/lock_test.go new file mode 100644 index 00000000..c474f30c --- /dev/null +++ b/modules/restic/cmd/restic-backups/lock_test.go @@ -0,0 +1,100 @@ +package main + +// The thinning waits for a repository another restic holds (novox/hq issue 620): met three times, each when a +// restic process outside the night's lock — a listing of the restore points while a delivery plan backs up a +// machine's data modules before their moves (ADR 0233) — held the repository's lock a few hundred milliseconds +// before the thinning asked for it, and the thinning failed at once. + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + "time" +) + +// lockedRestic is a fake restic whose repository another process holds for heldFor, as restic answers it: a +// forget without --retry-lock fails at once; one with it waits up to its bound, and succeeds when the lock is +// let go within it, else fails with restic's words. waited is how long the forgets it was asked waited. +type lockedRestic struct { + handRestic + heldFor time.Duration + waited []time.Duration +} + +const lockSaid = "unable to create lock in backend: repository is already locked by PID 4242 on the-machine by root " + + "(UID 0, GID 0) / lock was created at 2026-10-22 18:14:22 (246 ms ago)" + +func (l *lockedRestic) run(ctx context.Context, name string, args ...string) (string, error) { + if name != "restic" || args[5] != "forget" { + return l.handRestic.run(ctx, name, args...) + } + l.calls = append(l.calls, "restic "+strings.Join(args[5:], " ")) + bound := time.Duration(0) + if i := slices.Index(args, "--retry-lock"); i >= 0 && i+1 < len(args) { + d, err := time.ParseDuration(args[i+1]) + if err != nil { + return "", errors.New("invalid argument for --retry-lock: " + args[i+1]) + } + bound = d + } + if l.heldFor > bound { + l.waited = append(l.waited, bound) + return "", errors.New(lockSaid) + } + l.waited = append(l.waited, l.heldFor) + l.heldFor = 0 + return "", nil +} + +func TestTheThinningWaitsForALockHeldBriefly(t *testing.T) { + for _, held := range []time.Duration{3 * time.Second, 5 * time.Minute} { + l := &lockedRestic{heldFor: held} + b := &Backups{Where: placed(t, "# pg\npath /\n"), Run: l.run, Now: time.Now, Say: quiet} + _, err := b.BackUp(context.Background(), "") + must(t, err) + if th := b.LastRun().Thinned; th == nil || !th.OK { + t.Errorf("held for %s: recorded %+v, want the thinning to wait for the lock and succeed", held, th) + } + if why := b.Health(context.Background()).Why; strings.Contains(why, "Thinning the restore points failed") { + t.Errorf("held for %s: the health says %q", held, why) + } + } +} + +func TestTheThinningStillFailsOnALockHeldPastItsBound(t *testing.T) { + l := &lockedRestic{heldFor: thinningWaitsForALock + time.Minute} + b := &Backups{Where: placed(t, "# pg\npath /\n"), Run: l.run, Now: time.Now, Say: quiet} + _, err := b.BackUp(context.Background(), "") + must(t, err) + th := b.LastRun().Thinned + if th == nil || th.OK || !strings.Contains(th.Error, "already locked") { + t.Fatalf("recorded %+v, want the thinning failed on the lock", th) + } + if !slices.Equal(l.waited, []time.Duration{thinningWaitsForALock}) { + t.Errorf("waited %v, want once, for %s", l.waited, thinningWaitsForALock) + } + why := b.Health(context.Background()).Why + for _, want := range []string{"Thinning the restore points failed at", "already locked", "Nothing was forgotten", + "node-backup.now on this machine thins again"} { + if !strings.Contains(why, want) { + t.Errorf("the health says %q, want %q in it", why, want) + } + } +} + +// A restore point taken by hand that is let go by its id waits for the lock as well: it is the thinning too. +func TestTheThinningForgetsByIdWaitingForTheLock(t *testing.T) { + now := time.Date(2026, 10, 20, 3, 0, 0, 0, time.UTC) + l := &lockedRestic{heldFor: 3 * time.Second, handRestic: handRestic{made: "bbbbbbbb11", snaps: []Snapshot{ + {ID: "aaaaaaaa11", ShortID: "aaaaaaaa", Time: now.AddDate(0, 0, -9), Tags: []string{"module=pg", HandTag}}, + {ID: "bbbbbbbb11", ShortID: "bbbbbbbb", Time: now, Tags: []string{"module=pg"}}, + }}} + b := &Backups{Where: placed(t, "# pg\npath /\n"), Run: l.run, Now: func() time.Time { return now }, Say: quiet} + _, err := b.BackUp(context.Background(), "") + must(t, err) + if th := b.LastRun().Thinned; th == nil || !th.OK || !slices.Equal(th.LetGo, []string{"aaaaaaaa"}) { + t.Errorf("recorded %+v, want aaaaaaaa let go after waiting for the lock", th) + } +} diff --git a/modules/restic/cmd/restic-backups/retention_test.go b/modules/restic/cmd/restic-backups/retention_test.go index 2e0ce490..58a7ced4 100644 --- a/modules/restic/cmd/restic-backups/retention_test.go +++ b/modules/restic/cmd/restic-backups/retention_test.go @@ -51,7 +51,7 @@ func thinning(t *testing.T, retention *string) (string, []string) { func TestANightThinsByTheRetentionTheMeshWrote(t *testing.T) { conf := "# Written by the mesh.\ndaily=7\nweekly=4\nmonthly=24\nhand=10\n" got, _ := thinning(t, &conf) - if want := "restic forget --prune --group-by host,tags --keep-daily 7 --keep-weekly 4 --keep-monthly 24 --keep-tag hand"; got != want { + if want := "restic forget --prune --group-by host,tags --keep-daily 7 --keep-weekly 4 --keep-monthly 24 --keep-tag hand --retry-lock 10m0s"; got != want { t.Errorf("ran %q, want %q", got, want) } } diff --git a/modules/systemd/README.md b/modules/systemd/README.md index cf68a0c1..1ee8bfe2 100644 --- a/modules/systemd/README.md +++ b/modules/systemd/README.md @@ -6,4 +6,5 @@ The `systemd` module of the mesh catalogue: installs the package `systemd`; clai Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416). -- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). +- `${dir:data}` (`/var/lib/mesh/`): everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). +- `${dir:state}`: the package's own directory, declared for one delivery with the package's owner and mode so the node-engine gives it back (issue 1873); the module keeps nothing there. diff --git a/modules/systemd/cmd/systemd-tools/manifest_test.go b/modules/systemd/cmd/systemd-tools/manifest_test.go new file mode 100644 index 00000000..be356c78 --- /dev/null +++ b/modules/systemd/cmd/systemd-tools/manifest_test.go @@ -0,0 +1,52 @@ +package main + +// The module's directories (novox/hq issue 1873): its state directory said place ".", which is /var/lib/systemd, +// systemd's own directory (tmpfiles.d/systemd.conf: d /var/lib/systemd 0755 root root); the node-engine gave it to +// the operator's account, mode 0700, on every machine. What the tools keep moves to the mesh's directory for the +// module, and for one delivery the record "state" states the package's owner and mode, so the node-engine, which +// takes a directory it recorded at the same path under the same id as its own, gives it back. + +import ( + "encoding/json" + "os" + "testing" +) + +func manifestResources(t *testing.T) (map[string]map[string]any, map[string]any) { + t.Helper() + 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) + } + out := map[string]map[string]any{} + list, _ := m["resources"].([]any) + for _, x := range list { + r, _ := x.(map[string]any) + out[r["id"].(string)] = r + } + return out, m +} + +// 1873/AC1: the tools' record is kept in /mesh/systemd; "state" gives /var/lib/systemd back to root, 0755. +func TestTheToolsKeepTheirRecordOutsideSystemdsOwnDirectory(t *testing.T) { + rs, m := manifestResources(t) + data := rs["data"] + if data == nil || data["type"] != "directory" || data["place"] != "mesh" || data["path"] != nil { + t.Fatalf("the tools' directory is placed \"mesh\" with no path (/var/lib/mesh/systemd): %v", data) + } + build, _ := m["build"].(map[string]any) + artifacts, _ := build["artifacts"].([]any) + env := artifacts[0].(map[string]any)["env"].(map[string]any) + if env["MESH_SYSTEMD_STATE"] != "${dir:data}" { + t.Errorf("MESH_SYSTEMD_STATE is %v, not ${dir:data}", env["MESH_SYSTEMD_STATE"]) + } + state := rs["state"] + if state == nil || state["type"] != "directory" || state["path"] != "/var/lib/systemd" || state["owner"] != "root" || + state["mode"] != "0755" || state["place"] != nil { + t.Errorf("state gives /var/lib/systemd back as systemd declares it, root 0755: %v", state) + } +} diff --git a/modules/systemd/module.json b/modules/systemd/module.json index 984ccf3a..8c9403d2 100644 --- a/modules/systemd/module.json +++ b/modules/systemd/module.json @@ -41,7 +41,7 @@ "systemd-tools" ], "env": { - "MESH_SYSTEMD_STATE": "${dir:state}" + "MESH_SYSTEMD_STATE": "${dir:data}" } } ] @@ -53,10 +53,18 @@ "package": "systemd" }, { - "id": "state", + "id": "data", "type": "directory", "mode": "0700", - "place": ".", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/systemd", + "owner": "root", + "mode": "0755", "unknown": "program" } ] diff --git a/predefined.json b/predefined.json index 9e536767..56ba37d0 100644 --- a/predefined.json +++ b/predefined.json @@ -9,5 +9,10 @@ "description": "The size a desktop program shows its text in, in points.", "minimum": 6, "maximum": 72 + }, + "upgrade-order": { + "type": "list", + "description": "The nodes in the order they upgrade, each by its name: the first opens each week's round of upgrades, and each node upgrades only after the one before it passed its after-upgrade check. Empty or unset: no round starts.", + "trusted": true } }