Compare commits
18
Commits
fix/r4e-k2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89e15c7bcd | ||
|
|
a21da33d48 | ||
|
|
b53b0e7e2c | ||
|
|
48dd41db91 | ||
|
|
16331cdfdc | ||
|
|
ab3722d3b3 | ||
|
|
66c404fd39 | ||
|
|
3ad9d42be9 | ||
|
|
083515da1b | ||
|
|
ac5d257a1c | ||
|
|
896bd88380 | ||
|
|
24f50c7c86 | ||
|
|
e0bf87c2e3 | ||
|
|
ef1e98f867 | ||
|
|
e3db481491 | ||
|
|
21b4925892 | ||
|
|
098c6d3ec7 | ||
|
|
8d3f81c2c0 |
+181
-13
@@ -11,13 +11,23 @@
|
|||||||
// another module, after ${dir:<id>} is resolved within each module and both homes are read as one root.
|
// another module, after ${dir:<id>} 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).
|
// Archives are not containers for this rule. This holds for ~/.ssh what checks/sshdir held (ADR 0415 §6).
|
||||||
//
|
//
|
||||||
// go run . <catalogue root>
|
// 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/<module>, "mesh" /var/lib/mesh/<module>, neither /var/lib/<module>/<id>.
|
||||||
|
// 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 <trunk's system-known>] <catalogue root>
|
||||||
//
|
//
|
||||||
// Exits 1 on a finding.
|
// Exits 1 on a finding.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -27,6 +37,12 @@ import (
|
|||||||
"strings"
|
"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.
|
// Home is the one root both machine homes are read as.
|
||||||
const Home = "${home}"
|
const Home = "${home}"
|
||||||
|
|
||||||
@@ -43,9 +59,9 @@ type Finding struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type resource struct {
|
type resource struct {
|
||||||
ID, Type, Path, Unknown string
|
ID, Type, Path, Unknown, Place string
|
||||||
Knows []string
|
Knows []string
|
||||||
HasKnows bool
|
HasKnows bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type parsed struct {
|
type parsed struct {
|
||||||
@@ -65,6 +81,7 @@ func load(file string) (*parsed, error) {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
Place string `json:"place"`
|
||||||
Unknown *string `json:"unknown"`
|
Unknown *string `json:"unknown"`
|
||||||
Knows []string `json:"knows"`
|
Knows []string `json:"knows"`
|
||||||
} `json:"resources"`
|
} `json:"resources"`
|
||||||
@@ -81,7 +98,7 @@ func load(file string) (*parsed, error) {
|
|||||||
out.Module = filepath.Base(filepath.Dir(file))
|
out.Module = filepath.Base(filepath.Dir(file))
|
||||||
}
|
}
|
||||||
for _, r := range m.Resources {
|
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 {
|
if r.Unknown != nil {
|
||||||
x.Unknown = *r.Unknown
|
x.Unknown = *r.Unknown
|
||||||
if x.Unknown == "" {
|
if x.Unknown == "" {
|
||||||
@@ -143,14 +160,23 @@ type declared struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check reads every manifest under root/modules and answers what it refuses.
|
// 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"))
|
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sort.Strings(files)
|
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 out []Finding
|
||||||
var paths []declared
|
var paths []declared
|
||||||
|
collided := map[string]bool{}
|
||||||
type container struct{ module, path string }
|
type container struct{ module, path string }
|
||||||
var containers []container
|
var containers []container
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
@@ -170,6 +196,7 @@ func Check(root string) ([]Finding, error) {
|
|||||||
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
|
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out = append(out, inSystem(m, system, known, collided)...)
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
p := norm(r.Path, dirs)
|
p := norm(r.Path, dirs)
|
||||||
if p != "" {
|
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 _, d := range paths {
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) {
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
// systemDir is one directory a system package owns, and what says so.
|
||||||
root := "."
|
type systemDir struct{ Path, Source string }
|
||||||
if len(os.Args) > 1 {
|
|
||||||
root = os.Args[1]
|
// 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: "<absolute path> <package>: <how it was read>" 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/<directory> <package>: <how it was read>\"", 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 {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
for _, f := range found {
|
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 {
|
if len(found) > 0 {
|
||||||
os.Exit(1)
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-1
@@ -20,7 +20,10 @@ func manifest(t *testing.T, root, module, body string) {
|
|||||||
|
|
||||||
func check(t *testing.T, root string) []Finding {
|
func check(t *testing.T, root string) []Finding {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
func dir(id, path, rest string) string {
|
||||||
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
|
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
|
||||||
}
|
}
|
||||||
@@ -145,3 +162,87 @@ func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) {
|
|||||||
t.Fatalf("refused %+v", found)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: "<path> <package>: <how it was read>". 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
|
||||||
@@ -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: "<module> <resource id> issue <number>". 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
|
||||||
+15
-3
@@ -48,7 +48,8 @@
|
|||||||
# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it.
|
# 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):
|
# 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`
|
# 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.
|
# 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.
|
# 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.
|
# 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory.
|
||||||
@@ -73,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
|
# ~/.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.
|
# checks/sshdir stays until main runs checks/owned; it goes under issue 1035.
|
||||||
(cd checks/sshdir && go test -count=1 . && go run . ../..)
|
(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.
|
# 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 . ../..)
|
# 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
|
# 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.
|
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
|
||||||
secrets_base=""
|
secrets_base=""
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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):
|
||||||
|
* `<owner>/<name>.<number>.<head>.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, <repository>.<number>.<head>.<act>`);
|
||||||
|
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 <owner>/<name>`);
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, number>();
|
||||||
|
private seen = new Map<string, number>();
|
||||||
|
/** The turn each key's writes wait on, and the asks again still running. */
|
||||||
|
private turns = new Map<string, Promise<unknown>>();
|
||||||
|
private again = new Set<Promise<void>>();
|
||||||
|
private forge: CheckForge;
|
||||||
|
private say: (line: string) => void;
|
||||||
|
private sleep: (ms: number) => Promise<void>;
|
||||||
|
|
||||||
|
constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l),
|
||||||
|
sleep: (ms: number) => Promise<void> = (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<void> {
|
||||||
|
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<void> {
|
||||||
|
while (this.again.size) await Promise.all([...this.again]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async askAgain(change: OwedChange, first: unknown): Promise<void> {
|
||||||
|
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<string, number>, 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<void>) => Promise<void>, checks: CheckStatuses,
|
||||||
|
sleep?: (ms: number) => Promise<void>, say: (line: string) => void = (l) => console.error(l)): Promise<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -101,10 +101,10 @@ export function deliveryStatus(context: string, state: string, description: stri
|
|||||||
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
|
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`);
|
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
|
// 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. The forge bridge writes it through gitea_check_status, a tool
|
// state, as issue 293 closed for the two before it. No tool writes it, since a tool is told no caller (R4e finding K1):
|
||||||
// only its account may call (issue 1301).
|
// check.ts writes it from the checker's owed state (ADR 0521).
|
||||||
if (context === CHECK_STATUS) {
|
if (context === CHECK_STATUS) {
|
||||||
throw new Error(`${CHECK_STATUS} is the checker's status: the forge bridge writes it through gitea_check_status, never this tool`);
|
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
|
// 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
|
// them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq
|
||||||
@@ -136,7 +136,7 @@ export interface StatusWriters {
|
|||||||
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||||
return {
|
return {
|
||||||
name: "gitea_commit_status",
|
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. mesh/check, the status main requires, is refused: the checker's forge bridge writes it through gitea_check_status. 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.",
|
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: {
|
input: {
|
||||||
owner: { type: "string", description: "the repository owner" },
|
owner: { type: "string", description: "the repository owner" },
|
||||||
repo: { type: "string", description: "the repository name" },
|
repo: { type: "string", description: "the repository name" },
|
||||||
@@ -163,48 +163,3 @@ export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the check's status tool writes with: the forge. */
|
|
||||||
export interface CheckStatusWriter {
|
|
||||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `gitea_check_status` tool (novox/hq issue 1301, ADR 0447 §1): the one way `mesh/check` is written. Its arguments
|
|
||||||
* are gitea_commit_status's, so the forge bridge sends an owed status's arguments unchanged to it: owner, repo, sha
|
|
||||||
* (the head's whole sha), state, description, target_url, and context, which may be left out and is mesh/check when
|
|
||||||
* given. **Who may write it is the bus's grant**: the runtime tells a tool no caller, so only a module that lists
|
|
||||||
* `gitea.gitea_check_status` in its manifest's `invokes` may call it, and the forge bridge alone does. Served beside
|
|
||||||
* gitea_commit_status, from the watcher's bundle.
|
|
||||||
*/
|
|
||||||
export function checkStatusTool(writer: CheckStatusWriter): ToolDefinition {
|
|
||||||
return {
|
|
||||||
name: "gitea_check_status",
|
|
||||||
description: "Set mesh/check, the checker's verdict and the one status main requires, on a pull request's head: pending, success, error, failure or warning, a short description, and the page it links to. The checker's forge bridge alone may call it (its manifest's invokes); no other status is written here, and no other tool writes this one.",
|
|
||||||
input: {
|
|
||||||
owner: { type: "string", description: "the repository owner" },
|
|
||||||
repo: { type: "string", description: "the repository name" },
|
|
||||||
sha: { type: "string", description: "the head's whole sha" },
|
|
||||||
context: { type: "string", description: "mesh/check, or left out" },
|
|
||||||
state: { type: "string", description: "pending | success | error | failure | warning" },
|
|
||||||
description: { type: "string", description: "one line" },
|
|
||||||
target_url: { type: "string", description: "the page it links to (the check's report)" },
|
|
||||||
},
|
|
||||||
run: async (args) => {
|
|
||||||
const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? "");
|
|
||||||
if (args.context !== undefined && args.context !== null && String(args.context) !== CHECK_STATUS) {
|
|
||||||
throw new Error(`${JSON.stringify(args.context)}: this tool writes ${CHECK_STATUS} alone. Nothing was sent`);
|
|
||||||
}
|
|
||||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) {
|
|
||||||
throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: ${CHECK_STATUS} names the head whole. Nothing was sent`);
|
|
||||||
}
|
|
||||||
// deliveryStatus checks the state and cuts the description; it refuses mesh/check, so it is asked for a
|
|
||||||
// context of the mesh's own and the status is named mesh/check after.
|
|
||||||
const checked = deliveryStatus("mesh/delivery", String(args.state), String(args.description ?? ""),
|
|
||||||
args.target_url ? String(args.target_url) : undefined);
|
|
||||||
const status = { ...checked, context: CHECK_STATUS };
|
|
||||||
await writer.setCommitStatus(owner, repo, sha, status);
|
|
||||||
return { set: status };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-5
@@ -13,8 +13,12 @@
|
|||||||
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
|
// 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
|
// `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. Of the two, only those the base
|
// 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, written by its forge bridge
|
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere but hq, whose main
|
||||||
// through gitea_check_status — everywhere but hq, whose main requires `mesh/repo-check` alone (ADR 0503 §5).
|
// 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,
|
// 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.
|
// which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate.
|
||||||
@@ -41,7 +45,9 @@
|
|||||||
import { emit, on } from "@novox/mesh-sdk/events";
|
import { emit, on } from "@novox/mesh-sdk/events";
|
||||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||||
import { statesAnnouncement } from "./kept.js";
|
import { statesAnnouncement } from "./kept.js";
|
||||||
import { checkStatusTool, commitStatusTool } from "./delivery.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 { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||||
import { mergeTool } from "./merge.js";
|
import { mergeTool } from "./merge.js";
|
||||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||||
@@ -475,6 +481,11 @@ const gates = new GateWriter({
|
|||||||
headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha),
|
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
|
// **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
|
// 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.
|
// reach, nothing is served, as the tools' bundle serves nothing then.
|
||||||
@@ -491,8 +502,6 @@ await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", proc
|
|||||||
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
|
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
|
||||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||||
gates,
|
gates,
|
||||||
}), checkStatusTool({
|
|
||||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
|
||||||
}), mergeTool(gitea, { gates })] : [])]);
|
}), mergeTool(gitea, { gates })] : [])]);
|
||||||
|
|
||||||
// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when
|
// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when
|
||||||
@@ -621,4 +630,7 @@ if (gitea) {
|
|||||||
})],
|
})],
|
||||||
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
|
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
|
||||||
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
|
], () => 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<OwedChange["value"]>(CHECKER_OWED).watch((change) => handler(change)), checks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -543,6 +543,7 @@
|
|||||||
"ssh-client.keys",
|
"ssh-client.keys",
|
||||||
"mesh-build-go.keys",
|
"mesh-build-go.keys",
|
||||||
"mesh-build-node.keys",
|
"mesh-build-node.keys",
|
||||||
"mesh-build-image.keys"
|
"mesh-build-image.keys",
|
||||||
|
"mesh-delivery-checker.owed"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ 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). */
|
/** 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";
|
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 its forge bridge
|
/** The one status main requires (novox/hq ADR 0447 §1, issue 1301): the checker's verdict, written by this module from the
|
||||||
* through gitea_check_status. Its line names the head and the trunk commit it judged: "pass <head12> on <trunk12> …". */
|
* checker's owed state (check.ts, ADR 0521). Its line names the head and the trunk commit it judged: "pass <head12> on <trunk12> …". */
|
||||||
export const CHECK_STATUS = "mesh/check";
|
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
|
/** The statuses a branch's rule requires, from the rule the forge applies to it: none when it is unprotected or checks
|
||||||
|
|||||||
@@ -36,3 +36,76 @@ test("importing the bundle resolves while the broker keeps refusing the binds",
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
|
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<void>((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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<string, unknown> = {}) => ({ 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<void>((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<string, { value: unknown; revision: number }>();
|
||||||
|
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<void>) => {
|
||||||
|
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<string, unknown>).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");
|
||||||
|
});
|
||||||
@@ -62,37 +62,20 @@ test("one view per pull request, found by its marker; only the mesh's statuses",
|
|||||||
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
|
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Issue 1301-S4 (ADR 0447 §1; the lead's choice A): main requires mesh/check, so no hand may set it. The status tool
|
// 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; it is written only by gitea_check_status, which the forge bridge
|
// every caller may use refuses it in every state; no tool writes it at all: it is written from the checker's owed state
|
||||||
// alone lists in its manifest's `invokes`, so only its account may call it.
|
// (check.ts, test/check-owed.test.ts; ADR 0521).
|
||||||
test("mesh/check is refused by the status tool and written by the check's own tool alone", async () => {
|
test("mesh/check is refused by the status tool in every state", async () => {
|
||||||
const { commitStatusTool, checkStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
|
const { commitStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
|
||||||
assert.equal(CHECK_STATUS, "mesh/check");
|
assert.equal(CHECK_STATUS, "mesh/check");
|
||||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||||
const sent: string[] = [];
|
const sent: string[] = [];
|
||||||
const setCommitStatus = async (o: string, r: string, s: string, st: any) =>
|
const setCommitStatus = async (o: string, r: string, s: string, st: any) => void sent.push(`${o}/${r}@${s} ${st.context}`);
|
||||||
void sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
|
|
||||||
const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } };
|
const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } };
|
||||||
for (const state of ["pending", "success", "failure", "error", "warning"]) {
|
for (const state of ["pending", "success", "failure", "error", "warning"]) {
|
||||||
assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/);
|
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,
|
await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha,
|
||||||
context: "mesh/check", state, description: "by hand" }), /gitea_check_status/);
|
context: "mesh/check", state, description: "by hand" }), /mesh-delivery-checker\.owed/);
|
||||||
}
|
}
|
||||||
assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check");
|
assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check");
|
||||||
|
|
||||||
const tool = checkStatusTool({ setCommitStatus });
|
|
||||||
assert.equal(tool.name, "gitea_check_status");
|
|
||||||
for (const k of ["owner", "repo", "sha", "state", "description", "target_url", "context"]) assert.ok(k in tool.input, k);
|
|
||||||
// The bridge's arguments as it sends them for gitea_commit_status: the context may be given, and is mesh/check.
|
|
||||||
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/check", state: "success", description: "pass",
|
|
||||||
target_url: "https://forge.invalid/novox/mesh-sdk/pulls/1" });
|
|
||||||
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "pending", description: "y".repeat(300) });
|
|
||||||
assert.equal(sent[0], "novox/mesh-sdk@01234567 mesh/check success pass https://forge.invalid/novox/mesh-sdk/pulls/1");
|
|
||||||
assert.ok(sent[1].startsWith("novox/mesh-sdk@01234567 mesh/check pending yyyy"), sent[1]);
|
|
||||||
assert.ok(sent[1].length < 200, "the description is cut as every status's is");
|
|
||||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/delivery", state: "success" }), /mesh\/check alone/);
|
|
||||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha: sha.slice(0, 12), state: "success" }), /whole sha/);
|
|
||||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "green" }), /not a status/);
|
|
||||||
await assert.rejects(tool.run({ owner: "../x", repo: "mesh-sdk", sha, state: "success" }));
|
|
||||||
assert.equal(sent.length, 2, "nothing more was sent");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { GiteaClient } from "../dist/client.js";
|
|||||||
import { ConfiguredToken } from "../dist/token.js";
|
import { ConfiguredToken } from "../dist/token.js";
|
||||||
import { getGiteaTools } from "../dist/tools/index.js";
|
import { getGiteaTools } from "../dist/tools/index.js";
|
||||||
import { mergeTool } from "../dist/merge.js";
|
import { mergeTool } from "../dist/merge.js";
|
||||||
import { checkStatusTool, commitStatusTool } from "../dist/delivery.js";
|
import { commitStatusTool } from "../dist/delivery.js";
|
||||||
import { GateWriter } from "../dist/gate.js";
|
import { GateWriter } from "../dist/gate.js";
|
||||||
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
|
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
|
|||||||
} };
|
} };
|
||||||
const tools = [...getGiteaTools(client), commitStatusTool({
|
const tools = [...getGiteaTools(client), commitStatusTool({
|
||||||
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer,
|
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer,
|
||||||
}), checkStatusTool({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s) }), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
||||||
now: () => new Date(2026, 9, 12, 15, 1) })];
|
now: () => new Date(2026, 9, 12, 15, 1) })];
|
||||||
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
||||||
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
|
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
|
||||||
@@ -568,9 +568,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
|
|||||||
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
|
["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: "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: "../../../admin/users", context: "mesh/delivery", state: "success" }],
|
||||||
["gitea_check_status", { ...repo, sha: "abc1234?sudo=jschoubben", state: "success" }],
|
|
||||||
["gitea_check_status", { ...repo, sha: "../../../admin/users", state: "success" }],
|
|
||||||
["gitea_check_status", { owner: "novox", repo: "..", sha: "0123456789abcdef0123456789abcdef01234567", state: "success" }],
|
|
||||||
["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }],
|
["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }],
|
||||||
["gitea_commit_statuses", { ...repo, sha: ".." }],
|
["gitea_commit_statuses", { ...repo, sha: ".." }],
|
||||||
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
|
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import (
|
|||||||
|
|
||||||
// ADR 0499 §4 (issue 1758): a fetch asked with the module's directory and its named paths leaves the workspace holding
|
// 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
|
// 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.
|
// 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) {
|
func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) {
|
||||||
f := newForge(t)
|
f := newForge(t)
|
||||||
_, head := f.repo("novox/narrow", map[string]string{
|
_, head := f.repo("novox/narrow", map[string]string{
|
||||||
@@ -26,7 +27,7 @@ func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) {
|
|||||||
"other/o.go": "package other\n",
|
"other/o.go": "package other\n",
|
||||||
})
|
})
|
||||||
m, states := f.module(t)
|
m, states := f.module(t)
|
||||||
res, st := fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "dir": "lib", "paths": []any{"cmd"}})
|
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 {
|
if st.State != delivery.StepPassed {
|
||||||
t.Fatalf("the fetch ended %s: %s", st.State, st.Reason)
|
t.Fatalf("the fetch ended %s: %s", st.State, st.Reason)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||||
github.com/distribution/distribution/v3 v3.1.2
|
github.com/distribution/distribution/v3 v3.1.2
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
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.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
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 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
"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.
|
// 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) {
|
func (m *Module) look(ctx context.Context) (Look, error) {
|
||||||
l := Look{Account: m.Account}
|
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
|
var err error
|
||||||
if l.Buildah, err = m.buildahVersion(ctx); err != nil {
|
if l.Buildah, err = m.buildahVersion(ctx); err != nil {
|
||||||
return l, &buildstep.Refusal{Text: err.Error()}
|
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 {
|
if l.UIDs, l.GIDs, err = m.ranges(); err != nil {
|
||||||
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
|
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 {
|
if err := m.userNamespace(ctx); err != nil {
|
||||||
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
|
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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).
|
// 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 {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <dir>: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ module meshbuildimage
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
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-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
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.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
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 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||||
github.com/distribution/distribution/v3 v3.1.2
|
github.com/distribution/distribution/v3 v3.1.2
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
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.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
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 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module mesh-delivery-gate
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
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-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
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.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
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 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ type Client struct {
|
|||||||
// Now dates the reports; nil is time.Now.
|
// Now dates the reports; nil is time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
// SocketUp wakes the client when the node-engine's write socket is made again (watchSocket); nil never does.
|
// 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{}
|
SocketUp <-chan struct{}
|
||||||
|
|
||||||
// watchBound watches a provider's bucket of entries for this machine's keys, once per run (consumers.go); set by
|
// 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.
|
// of the consumers left out of one of its files.
|
||||||
providing map[string]bool
|
providing map[string]bool
|
||||||
leftOut map[string]string
|
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
|
unplaced map[string]bool
|
||||||
// bound is each provider's bucket of entries this client watches, by key: this machine's consumers' entries alone;
|
// 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.
|
// 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)
|
err = errFill(a)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if socketAbsent(err) {
|
// Filled again when a node-engine makes its socket again: one gone (issue 1165), or one that refused for a
|
||||||
s.unplaced[module] = true
|
// 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))
|
failed = append(failed, fmt.Sprintf("%s: %v", f.Path, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -106,3 +106,38 @@ func TestWatchSocketWakesWhenTheSocketIsMadeAndWhenItsDirectoryReturns(t *testin
|
|||||||
defer l.Close()
|
defer l.Close()
|
||||||
heard("the directory and socket back")
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func TestTheKeeperIsAProcessUnderItsOwnAccountWithABusAccountOfItsOwn(t *testing
|
|||||||
t.Errorf("the bus account is the keeper's own secret, owned by its account: %v", secrets)
|
t.Errorf("the bus account is the keeper's own secret, owned by its account: %v", secrets)
|
||||||
}
|
}
|
||||||
state := m.resource(t, "state")
|
state := m.resource(t, "state")
|
||||||
if state["type"] != "directory" || state["place"] != "." || state["unknown"] == nil {
|
if state["type"] != "directory" || state["place"] != "mesh" || state["unknown"] == nil {
|
||||||
t.Errorf("the state directory: %v", state)
|
t.Errorf("the state directory: %v", state)
|
||||||
}
|
}
|
||||||
order := map[string]int{}
|
order := map[string]int{}
|
||||||
@@ -211,3 +211,37 @@ func TestUpgradeOrderIsATrustedListOfTheMesh(t *testing.T) {
|
|||||||
t.Error("a value falls back only to a key of its own type (ADR 0348 §4)")
|
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", <data root>/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -248,12 +248,12 @@ func tools(m *Machine) []stdio.Tool {
|
|||||||
Name: "pacman_upgrades",
|
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, " +
|
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; " +
|
"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 node and a run, that run whole, every step with its time.",
|
"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{
|
Input: schema(map[string]any{
|
||||||
"node": map[string]any{"type": "string", "description": "only this node's runs"},
|
"node": map[string]any{"type": "string", "description": "only this node's runs"},
|
||||||
"from": sinceArg,
|
"from": sinceArg,
|
||||||
"until": map[string]any{"type": "string", "description": "a day, YYYY-MM-DD, the last one listed"},
|
"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, with node"},
|
"run": map[string]any{"type": "string", "description": "one run, by its id: of node, else of this node"},
|
||||||
"limit": limitArg(50, 1000),
|
"limit": limitArg(50, 1000),
|
||||||
}),
|
}),
|
||||||
Run: func(args map[string]any) (any, error) {
|
Run: func(args map[string]any) (any, error) {
|
||||||
@@ -269,6 +269,11 @@ func tools(m *Machine) []stdio.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
return ListUpgrades(upgradeState(StateRuns), v[0], v[1], v[2], v[3], n)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ func (u *Upgrades) StartRollback(of string, removeAdded bool, ask string) (Run,
|
|||||||
if o.Run.Run == r.Run.Run {
|
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)
|
return r.Run, fmt.Errorf("run %s of %s is there already: one run a second", o.Run.Run, u.Node)
|
||||||
}
|
}
|
||||||
if !ended(o.Run) && o.From != FromLog {
|
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)))
|
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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -63,6 +63,22 @@ func ended(r Run) bool {
|
|||||||
return false
|
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.
|
// ownRuns are the node's runs, oldest first, each with the revision of its key.
|
||||||
func (u *Upgrades) ownRuns() ([]openRun, error) {
|
func (u *Upgrades) ownRuns() ([]openRun, error) {
|
||||||
keys, err := u.Runs.Keys()
|
keys, err := u.Runs.Keys()
|
||||||
@@ -117,7 +133,7 @@ func (u *Upgrades) startUpgrade(r *openRun) error {
|
|||||||
if o.Run.Run == r.Run.Run {
|
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)
|
return fmt.Errorf("run %s of %s is there already: one run a second", o.Run.Run, u.Node)
|
||||||
}
|
}
|
||||||
if !ended(o.Run) {
|
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)))
|
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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module pacman
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
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-server/v2 v2.11.17
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
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.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
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 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
"id": "state",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"mode": "0711",
|
"mode": "0711",
|
||||||
"place": ".",
|
"place": "mesh",
|
||||||
"unknown": "program"
|
"unknown": "program"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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).
|
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/<module>`): 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).
|
- `${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).
|
- `/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).
|
- `${dir:store-data}/dumps`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||||
|
|||||||
@@ -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 <data root>/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,8 +61,8 @@
|
|||||||
"postgres-database": "${dir:grants}"
|
"postgres-database": "${dir:grants}"
|
||||||
},
|
},
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"superuser": "${dir:state}/superuser.secret",
|
"superuser": "${dir:data}/superuser.secret",
|
||||||
"reader": "${dir:state}/reader.secret"
|
"reader": "${dir:data}/reader.secret"
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"own": [
|
"own": [
|
||||||
@@ -93,22 +93,31 @@
|
|||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"mode": "0700",
|
"mode": "0700",
|
||||||
"place": ".",
|
"place": "mesh",
|
||||||
|
"unknown": "program"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/var/lib/postgres",
|
||||||
|
"owner": "postgres",
|
||||||
|
"mode": "0700",
|
||||||
"unknown": "program"
|
"unknown": "program"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "grants",
|
"id": "grants",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
|
"path": "${dir:data}/grants",
|
||||||
"mode": "0700",
|
"mode": "0700",
|
||||||
"unknown": "program"
|
"unknown": "program"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "consumers",
|
"id": "consumers",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/consumers.json",
|
"path": "${dir:data}/consumers.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"template": "files/consumers.json"
|
"template": "files/consumers.json"
|
||||||
},
|
},
|
||||||
@@ -146,7 +155,7 @@
|
|||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh-store:/var/lib/postgresql/data",
|
"/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": {
|
"env": {
|
||||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
|
"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_RECEIVES": "${dir:grants}/mesh.json",
|
||||||
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret",
|
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:data}/reader.secret",
|
||||||
"MESH_CONSUMERS_FILE": "${dir:state}/consumers.json"
|
"MESH_CONSUMERS_FILE": "${dir:data}/consumers.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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).
|
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/<module>`): 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.
|
||||||
|
|||||||
@@ -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 <data root>/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"systemd-tools"
|
"systemd-tools"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_SYSTEMD_STATE": "${dir:state}"
|
"MESH_SYSTEMD_STATE": "${dir:data}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -53,10 +53,18 @@
|
|||||||
"package": "systemd"
|
"package": "systemd"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"mode": "0700",
|
"mode": "0700",
|
||||||
"place": ".",
|
"place": "mesh",
|
||||||
|
"unknown": "program"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/var/lib/systemd",
|
||||||
|
"owner": "root",
|
||||||
|
"mode": "0755",
|
||||||
"unknown": "program"
|
"unknown": "program"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user