Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53ac973e9 | ||
|
|
f1f18bf2bd | ||
|
|
c41c968086 | ||
|
|
939475cb63 | ||
|
|
5e24deb0f1 |
@@ -0,0 +1,3 @@
|
||||
# The modules the check covers (novox/hq ADR 0507 §9, issue 1838), one a line; issue 1838 widens it.
|
||||
pacman
|
||||
kernel
|
||||
+26
-163
@@ -1,23 +1,12 @@
|
||||
// Command particulars keeps this mesh's node names out of the modules it covers (novox/hq ADR 0507 §9,
|
||||
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
|
||||
// fixture of any module names a node of this one.
|
||||
// fixture of a covered module names a node of this one.
|
||||
//
|
||||
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
|
||||
// they may be written. Every directory under modules/ is read, with no list of covered modules, so a new module
|
||||
// is covered from its first commit. A name is matched whole and in any case; a hyphen, an underscore or a
|
||||
// CamelCase boundary parts one, so a longer word holding a name is another word.
|
||||
// Some text holds a node name and is not one, so it is blanked before a line is read, each form exact: the
|
||||
// software's module path (git.novox.be/novox/<repo>), the name of the software's record (novox/hq, followed by
|
||||
// anything), the hardware model (asus-zephyrus-g14, ROG Zephyrus G14), and the software's own
|
||||
// name and npm scope (Novox Mesh, novox-mesh, @novox/mesh-sdk with nothing before the @). Any other novox/<x>,
|
||||
// the forge's host alone and a node after a word's @ (database@novox/postgres) are findings.
|
||||
//
|
||||
// A module may declare its hardware model's word in `particulars`, as a line `model <module> <word>`: the word,
|
||||
// one of the names, is then no finding in that module's directory and still one in every other
|
||||
// (asus-zephyrus-g14's adopted paths such as /etc/modprobe.d/g14-nvidia-power.conf). A declaration naming no
|
||||
// module or no listed name makes the list unreadable.
|
||||
// A hardware's own name holding a name is declared as a line `hardware <word>` (asus-g14): that exact word, in
|
||||
// any case and not part of a longer hyphened word, is no finding in any module.
|
||||
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
|
||||
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
|
||||
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
|
||||
// before a line is read.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
@@ -32,22 +21,13 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// forge is the software's module path on its forge and the name of its record (novox/hq, in any form: a citation,
|
||||
// a review, a URL path), blanked before a line is read; any other novox/<x> (a repository in a fixture,
|
||||
// testdata/novox/x) is not blanked, nor novox/hqx.
|
||||
var forge = regexp.MustCompile(`(?i:git\.novox\.be/novox/[\w.-]+)|novox/hq(?:$|[^\w-])`)
|
||||
|
||||
// ownNames are the hardware model's and the software's own names, blanked before a line is read: the model
|
||||
// in any separator and case (asus-zephyrus-g14, ASUS_ZEPHYRUS_G14, ROG Zephyrus G14), the software as two
|
||||
// words or hyphened, whole (Novox Mesh, novox mesh, novox-mesh), so novox-mesh-vault stays a node name joined;
|
||||
// and the software's npm scope (@novox/mesh-sdk, @novox:registry=), its package path as the Go module path is,
|
||||
// where no word stands before the @, so a session's who@node (fix-1@novox) is still a node name.
|
||||
var ownNames = regexp.MustCompile(`(?i)(?:asus[-_ ])?zephyrus[-_ ]g14|(?:^|[^\w-])novox[- ]mesh(?:$|[^\w-])|(?:^|[^\w.-])@novox(?:$|[^\w-])`)
|
||||
// forge is the forge's host and organisation path, blanked before a line is read; a path segment novox/ elsewhere
|
||||
// (testdata/novox/x, /srv/novox/data) is not blanked.
|
||||
var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w.-])novox/[\w.-]+`)
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
@@ -59,30 +39,22 @@ func main() {
|
||||
|
||||
// check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable.
|
||||
func check(root string) int {
|
||||
entries, err := readList(filepath.Join(root, "particulars"))
|
||||
names, models, hardware := splitModels(entries)
|
||||
names, err := readList(filepath.Join(root, "particulars"))
|
||||
if err == nil && len(names) == 0 {
|
||||
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
|
||||
}
|
||||
var modules []string
|
||||
var covered []string
|
||||
if err == nil {
|
||||
modules, err = moduleDirs(root)
|
||||
if err == nil && len(modules) == 0 {
|
||||
err = fmt.Errorf("modules/ holds no module — the check would pass on anything")
|
||||
covered, err = readList(filepath.Join(root, "checks/particulars/covered"))
|
||||
if err == nil && len(covered) == 0 {
|
||||
err = fmt.Errorf("covered lists no module — the check would pass on anything")
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = checkModels(models, names, modules)
|
||||
}
|
||||
var blanks *regexp.Regexp
|
||||
if err == nil {
|
||||
blanks, err = hardwareWords(hardware)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||
return 2
|
||||
}
|
||||
findings, err := run(root, names, modules, models, blanks)
|
||||
findings, err := run(root, names, covered)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||
return 2
|
||||
@@ -94,7 +66,7 @@ func check(root string) int {
|
||||
fmt.Printf("particulars: %d node name(s) in a module for any mesh (novox/hq ADR 0507 §9) — say what it shows, not where it was seen\n", len(findings))
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("particulars: none of %d node names in the %d modules\n", len(names), len(modules))
|
||||
fmt.Printf("particulars: none of %d node names in the %d covered modules\n", len(names), len(covered))
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -117,104 +89,10 @@ func readList(path string) ([]string, error) {
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// splitModels parts the list's entries into the names, the models' words by module (`model <module> <word>`)
|
||||
// and the hardware's words (`hardware <word>`, kept whole to be checked).
|
||||
func splitModels(entries []string) (names []string, models map[string][]string, hardware []string) {
|
||||
models = map[string][]string{}
|
||||
for _, e := range entries {
|
||||
f := strings.Fields(e)
|
||||
if len(f) > 0 && f[0] == "hardware" {
|
||||
hardware = append(hardware, e)
|
||||
continue
|
||||
}
|
||||
if len(f) > 0 && f[0] == "model" {
|
||||
if len(f) != 3 {
|
||||
models[""] = append(models[""], e)
|
||||
continue
|
||||
}
|
||||
models[f[1]] = append(models[f[1]], f[2])
|
||||
continue
|
||||
}
|
||||
names = append(names, e)
|
||||
}
|
||||
return names, models, hardware
|
||||
}
|
||||
|
||||
// hardwareWords is the matcher of the declared hardware words, nil when none is declared; a line that is not
|
||||
// `hardware <word>` makes the list unreadable.
|
||||
func hardwareWords(lines []string) (*regexp.Regexp, error) {
|
||||
var words []string
|
||||
for _, l := range lines {
|
||||
f := strings.Fields(l)
|
||||
if len(f) != 2 {
|
||||
return nil, fmt.Errorf("particulars: %q is not `hardware <word>`", l)
|
||||
}
|
||||
words = append(words, regexp.QuoteMeta(f[1]))
|
||||
}
|
||||
if len(words) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9-])(?:` + strings.Join(words, "|") + `)(?:$|[^A-Za-z0-9-])`), nil
|
||||
}
|
||||
|
||||
// blankWith blanks what rx matches in s, keeping every other byte where it was.
|
||||
func blankWith(rx *regexp.Regexp, s string) string {
|
||||
if rx == nil {
|
||||
return s
|
||||
}
|
||||
return rx.ReplaceAllStringFunc(s, func(m string) string { return strings.Repeat(" ", len(m)) })
|
||||
}
|
||||
|
||||
// checkModels refuses a model declaration that is malformed, names no module or names a word that is no name.
|
||||
func checkModels(models map[string][]string, names, modules []string) error {
|
||||
for m, words := range models {
|
||||
if m == "" {
|
||||
return fmt.Errorf("particulars: %q is not `model <module> <word>`", words[0])
|
||||
}
|
||||
if !slices.Contains(modules, m) {
|
||||
return fmt.Errorf("particulars: model %s %s names no module under modules/", m, words[0])
|
||||
}
|
||||
for _, w := range words {
|
||||
if !slices.ContainsFunc(names, func(n string) bool { return strings.EqualFold(n, w) }) {
|
||||
return fmt.Errorf("particulars: model %s %s: %q is not a listed name", m, w, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// without returns the names but the words, in any case.
|
||||
func without(names, words []string) []string {
|
||||
// run reads every text file of each covered module and returns the findings, sorted.
|
||||
func run(root string, names, covered []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
if !slices.ContainsFunc(words, func(w string) bool { return strings.EqualFold(n, w) }) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// moduleDirs returns every directory under the catalogue's modules/, sorted.
|
||||
func moduleDirs(root string) ([]string, error) {
|
||||
entries, err := os.ReadDir(filepath.Join(root, "modules"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// run reads every text file of each module named and returns the findings, sorted; a module's model words are
|
||||
// not read as names in it.
|
||||
func run(root string, all, modules []string, models map[string][]string, hardware *regexp.Regexp) ([]string, error) {
|
||||
var out []string
|
||||
for _, m := range modules {
|
||||
names := without(all, models[m])
|
||||
for _, m := range covered {
|
||||
dir := filepath.Join(root, "modules", m)
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -229,7 +107,7 @@ func run(root string, all, modules []string, models map[string][]string, hardwar
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
// A path is read too: a testdata directory named after a node is a finding.
|
||||
for _, n := range hits(blankWith(hardware, filepath.ToSlash(rel)), names) {
|
||||
for _, n := range hits(filepath.ToSlash(rel), names) {
|
||||
out = append(out, fmt.Sprintf("%s:0: node name %q in the path", filepath.ToSlash(rel), n))
|
||||
}
|
||||
if d.Name() == "go.sum" {
|
||||
@@ -242,7 +120,7 @@ func run(root string, all, modules []string, models map[string][]string, hardwar
|
||||
if bytes.IndexByte(b, 0) >= 0 {
|
||||
return nil
|
||||
}
|
||||
out = append(out, scan(filepath.ToSlash(rel), blankWith(hardware, string(b)), names)...)
|
||||
out = append(out, scan(filepath.ToSlash(rel), string(b), names)...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -265,32 +143,17 @@ func scan(file, src string, names []string) []string {
|
||||
}
|
||||
|
||||
// hits returns the node names one line holds: as a word (a hyphen or underscore parts it), as a part of a
|
||||
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path and the hardware's and the
|
||||
// software's own names blanked first.
|
||||
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path blanked first.
|
||||
func hits(line string, names []string) []string {
|
||||
blank := func(s string) string { return strings.Repeat(" ", len(s)) }
|
||||
line = forge.ReplaceAllStringFunc(line, blank)
|
||||
line = ownNames.ReplaceAllStringFunc(line, blank)
|
||||
line = forge.ReplaceAllStringFunc(line, func(s string) string { return strings.Repeat(" ", len(s)) })
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
if matcher(n).MatchString(line) {
|
||||
q := regexp.QuoteMeta(n)
|
||||
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
|
||||
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
|
||||
if rx.MatchString(line) {
|
||||
out = append(out, strings.ToLower(n))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchers holds each name's matcher, compiled once: the check reads every line of every module.
|
||||
var matchers = map[string]*regexp.Regexp{}
|
||||
|
||||
// matcher is the matcher of one name: as a word, or as a part of a CamelCase identifier.
|
||||
func matcher(n string) *regexp.Regexp {
|
||||
if rx, ok := matchers[n]; ok {
|
||||
return rx
|
||||
}
|
||||
q := regexp.QuoteMeta(n)
|
||||
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
|
||||
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
|
||||
matchers[n] = rx
|
||||
return rx
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ func TestTheForgeIsNotANodeName(t *testing.T) {
|
||||
"see novox/hq issue 5",
|
||||
"module git.novox.be/novox/mesh-sdk/go v1.2.3",
|
||||
"https://git.novox.be/novox/mesh-catalog",
|
||||
"git.novox.be",
|
||||
"space trace surface grace g14x",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 0 {
|
||||
@@ -67,7 +68,7 @@ func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
|
||||
p := filepath.Join(root, "modules/kernel/testdata/ace/x")
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte("clean\n"), 0o644)
|
||||
got, err := run(root, names, []string{"kernel"}, nil, nil)
|
||||
got, err := run(root, names, []string{"kernel"})
|
||||
if err != nil || len(got) != 1 || !strings.HasPrefix(got[0], "modules/kernel/testdata/ace/x:0: ") {
|
||||
t.Errorf("got %v, %v", got, err)
|
||||
}
|
||||
@@ -81,6 +82,7 @@ func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "ace\n")
|
||||
write("checks/particulars/covered", "kernel\n")
|
||||
write("modules/kernel/a", "clean\n")
|
||||
if got := check(root); got != 0 {
|
||||
t.Errorf("clean = %d, want 0", got)
|
||||
@@ -95,7 +97,7 @@ func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReadsOnlyTheModulesItIsGiven(t *testing.T) {
|
||||
func TestOnlyCoveredModulesAreRead(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
@@ -104,7 +106,7 @@ func TestRunReadsOnlyTheModulesItIsGiven(t *testing.T) {
|
||||
}
|
||||
write("modules/kernel/files/a", "on novox\n")
|
||||
write("modules/other/files/a", "on novox\n")
|
||||
got, err := run(root, names, []string{"kernel"}, nil, nil)
|
||||
got, err := run(root, names, []string{"kernel"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -112,201 +114,3 @@ func TestRunReadsOnlyTheModulesItIsGiven(t *testing.T) {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC1: the check reads every directory under modules/ and no list of covered modules, so a module is
|
||||
// covered from its first commit.
|
||||
func TestEveryModuleIsReadWithoutAList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "novox\n")
|
||||
write("modules/kernel/files/a", "clean\n")
|
||||
if got := check(root); got != 0 {
|
||||
t.Errorf("clean, no covered list = %d, want 0", got)
|
||||
}
|
||||
write("modules/new-module/files/a", "on novox\n")
|
||||
if got := check(root); got != 1 {
|
||||
t.Errorf("a finding in a module no list names = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: the hardware model's own name and the software's own name are not node names.
|
||||
func TestTheHardwareModelAndTheSoftwareAreNotNodeNames(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"modules/asus-zephyrus-g14/README.md",
|
||||
"the asus-zephyrus-g14 module",
|
||||
"ASUS_ZEPHYRUS_G14=1",
|
||||
"product_family: ROG Zephyrus G14",
|
||||
"# This machine is a node of a Novox mesh",
|
||||
"Novox Mesh turns a handful of personal machines into one system",
|
||||
"<!-- novox mesh: this file was changed on two nodes",
|
||||
`const sealInfo = "novox-mesh sealed box v1"`,
|
||||
"module git.novox.be/novox/mesh-catalog/modules/kernel",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 0 {
|
||||
t.Errorf("%q refused: %v", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: a node name standing alone is still one, beside the hardware's or the software's name too.
|
||||
func TestANodeNameBesideTheHardwareOrTheSoftwareIsRefused(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"g14": "the asus-zephyrus-g14 module on g14",
|
||||
"shanks": "Novox Mesh on shanks",
|
||||
"novox": "resolves under novox.internal",
|
||||
"ace": "ROG Zephyrus G14 beside ace",
|
||||
"novox mesh-ish": "mesh.mod.gitea.call.x.novox-mesh-vault",
|
||||
}
|
||||
for want, s := range cases {
|
||||
got := scan("modules/m/a.go", s, names)
|
||||
if len(got) != 1 || !strings.HasPrefix(got[0], "modules/m/a.go:1: ") {
|
||||
t.Errorf("%s: %q gave %v, want one finding naming file:line", want, s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: the software's npm scope (@novox/mesh-sdk, @novox:registry=) is its package path, as the Go module
|
||||
// path is; a session's who@node naming a node is still a node name.
|
||||
func TestTheSoftwaresNpmScopeIsNotANodeName(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)}",
|
||||
"const url = `${asked}@novox%2fmesh-sdk`;",
|
||||
"# The @novox scope resolves from the mesh's own package registry",
|
||||
`"dependencies": { "@novox/mesh-sdk": "^0.1.0" }`,
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 0 {
|
||||
t.Errorf("%q refused: %v", s, got)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"by fix-1@novox", "jochen@novox:"} {
|
||||
if got := scan("f", s, names); len(got) != 1 {
|
||||
t.Errorf("%q gave %v, want one finding", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC1: reading every module, the check compiles its matchers once, not once a line: a line read costs a
|
||||
// few allocations, not a regexp compiled per name (the whole catalogue took 41 s with one per line).
|
||||
func TestALineIsReadWithoutCompilingAMatcher(t *testing.T) {
|
||||
line := "a line of code that names nothing at all, with git.novox.be/novox/mesh-sdk in it"
|
||||
if n := testing.AllocsPerRun(100, func() { hits(line, names) }); n > 20 {
|
||||
t.Errorf("a line costs %.0f allocations, want at most 20", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: a module may declare its hardware model's word in `particulars` (model <module> <word>); the word is
|
||||
// then no finding in that module, and still one in every other.
|
||||
func TestAModelsWordIsAllowedInItsModuleOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "ace\ng14\nmodel asus-zephyrus-g14 g14\n")
|
||||
write("modules/asus-zephyrus-g14/README.md", "the adopted /etc/modprobe.d/g14-nvidia-power.conf; a second G14\n")
|
||||
write("modules/kernel/a", "clean\n")
|
||||
if got := check(root); got != 0 {
|
||||
t.Errorf("the model's word in its own module = %d, want 0", got)
|
||||
}
|
||||
write("modules/asus-zephyrus-g14/a", "on ace\n")
|
||||
if got := check(root); got != 1 {
|
||||
t.Errorf("another node name in the model's module = %d, want 1", got)
|
||||
}
|
||||
write("modules/asus-zephyrus-g14/a", "clean\n")
|
||||
write("modules/kernel/a", "i3's 20-g14.conf\n")
|
||||
if got := check(root); got != 1 {
|
||||
t.Errorf("the model's word in another module = %d, want 1", got)
|
||||
}
|
||||
write("modules/kernel/a", "clean\n")
|
||||
write("particulars", "ace\ng14\nmodel no-such-module g14\n")
|
||||
if got := check(root); got != 2 {
|
||||
t.Errorf("a model of a module that does not exist = %d, want 2", got)
|
||||
}
|
||||
write("particulars", "ace\ng14\nmodel asus-zephyrus-g14 shanks\n")
|
||||
if got := check(root); got != 2 {
|
||||
t.Errorf("a model's word that is no listed name = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: only the module path git.novox.be/novox/<repo>, a package name or scope with nothing before its @, and
|
||||
// the exact record citation novox/hq ADR|issue|design|to-be|research are the forge's or the software's; any
|
||||
// other novox/<x>, the forge's host alone, and a node after a word's @ are findings.
|
||||
func TestOnlyTheModulePathThePackageAndTheCitationAreNotNodeNames(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"module git.novox.be/novox/mesh-sdk/go v1.2.3",
|
||||
"https://git.novox.be/novox/mesh-catalog",
|
||||
`import { registerModuleTools } from "@novox/mesh-sdk/tools";`,
|
||||
`"dependencies": { "@novox/ui": "^2.0.0" }`,
|
||||
"// novox/hq ADR 0507 §9",
|
||||
"(novox/hq issue 1838)",
|
||||
"novox/hq design 47 and novox/hq to-be 53, novox/hq research 12",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 0 {
|
||||
t.Errorf("%q refused: %v", s, got)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
`handedWith("node-b", "gitea", "postgres-database@novox/postgres+pair")`,
|
||||
"delivery.novox/mesh-controller",
|
||||
"see novox/mesh-catalog #441",
|
||||
"git.novox.be",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 1 {
|
||||
t.Errorf("%q gave %v, want one finding", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: novox/hq is the name of the software's own record, no finding in any form; another novox/<repo> is.
|
||||
func TestTheRecordsNameIsNoFindingInAnyForm(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"novox/hq review round 2",
|
||||
"novox/hq issues 1665 and 1666",
|
||||
"repository: novox/hq",
|
||||
"[the record](https://forge.example.org/novox/hq/src/branch/main/00-META/glossary.md)",
|
||||
"novox/hq#7 at aaa",
|
||||
"git.anchor.internal:222/novox/hq.git",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 0 {
|
||||
t.Errorf("%q refused: %v", s, got)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{`repo := "novox/mesh-catalog"`, "novox/hqx", "novox/hq-mirror"} {
|
||||
if got := scan("f", s, names); len(got) != 1 {
|
||||
t.Errorf("%q gave %v, want one finding", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: a hardware word declared in particulars (hardware <word>) is no finding anywhere; a name standing
|
||||
// alone still is.
|
||||
func TestAHardwareWordIsNoFindingAnywhere(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "g14\nhardware asus-g14\n")
|
||||
write("modules/triggerhappy/a", "/etc/triggerhappy/triggers.d/asus-g14.conf and ASUS-G14\n")
|
||||
if got := check(root); got != 0 {
|
||||
t.Errorf("the hardware word = %d, want 0", got)
|
||||
}
|
||||
write("modules/triggerhappy/a", "asus-g14.conf on g14\n")
|
||||
if got := check(root); got != 1 {
|
||||
t.Errorf("g14 standing alone beside it = %d, want 1", got)
|
||||
}
|
||||
write("modules/triggerhappy/a", "my-asus-g14-x\n")
|
||||
if got := check(root); got != 1 {
|
||||
t.Errorf("a longer word holding it = %d, want 1", got)
|
||||
}
|
||||
write("particulars", "g14\nhardware\n")
|
||||
if got := check(root); got != 2 {
|
||||
t.Errorf("a hardware line with no word = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -55,8 +55,8 @@
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in any module (every directory under
|
||||
# modules/ is covered, with no list); the names, and a module's hardware model word, are in `particulars`.
|
||||
# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in a covered module; the names are in
|
||||
# `particulars`, the covered modules in checks/particulars/covered.
|
||||
(cd checks/particulars && go test -count=1 . && go run . ../..)
|
||||
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
|
||||
(cd checks/toolchains && go test -count=1 . && go run . ../..)
|
||||
|
||||
@@ -86,7 +86,7 @@ login.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | node-b | node-d |
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| packages | none (all present) | the same |
|
||||
| GTK settings, `portals.conf` | the found files kept once, then the module's: adds the cursor and Inter, keeps Adwaita dark; `portals.conf` adds the Secret line | the same |
|
||||
|
||||
@@ -94,8 +94,7 @@ func TestTheManifestNamesNoMachineAndItsTriggersExist(t *testing.T) {
|
||||
ids[r["id"].(string)] = true
|
||||
}
|
||||
raw, _ := os.ReadFile("../../module.json")
|
||||
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
|
||||
for _, banned := range []string{"jochen", "/home/", "/run/user/1000"} {
|
||||
for _, banned := range []string{"jochen", "/home/", "/run/user/1000", "\"g14\"", "shanks"} {
|
||||
if strings.Contains(string(raw), banned) {
|
||||
t.Errorf("the manifest says %q", banned)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# betterbird
|
||||
|
||||
Betterbird, the mail and calendar client of node-b and node-d (a Thunderbird fork, with the operator's
|
||||
Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's
|
||||
Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, issue 586, ADR 0208, ADR 0316,
|
||||
ADR 0317, ADR 0329, to-be 56). It requires `x11-display`, so it is assigned only where a display server is held
|
||||
on the same machine. It installs Betterbird from a pinned upstream release, ships Owl with it through
|
||||
@@ -58,7 +58,7 @@ reading the new range there (ADR 0316 §8).
|
||||
**The policy** turns Betterbird's own updater off (`DisableAppUpdate`, which Betterbird's update check
|
||||
honours) and installs Owl into every profile from `file:///opt/betterbird/owl/owl.xpi` as
|
||||
`force_installed`, with `updates_disabled`. A profile that already runs another Owl keeps it: the policy
|
||||
installs the pinned one where Owl is missing. A newer Owl in the profile (node-b runs 1.5.7) is said as a
|
||||
installs the pinned one where Owl is missing. A newer Owl in the profile (g14 runs 1.5.7) is said as a
|
||||
fact by `betterbird_status`; Owl installed by policy cannot be removed in Betterbird, so the remedy is
|
||||
moving the pin. An older one is a finding of `betterbird_check`, with the same remedy.
|
||||
|
||||
@@ -323,10 +323,10 @@ question.
|
||||
|
||||
## What changes when it is pushed
|
||||
|
||||
The module is assigned to node-d today (look-only, from issue 345's first part); node-b runs Betterbird
|
||||
The module is assigned to shanks today (look-only, from issue 345's first part); g14 runs Betterbird
|
||||
outside the mesh. With this version, a push to a node (each waits for the operator: `upgrade: record`):
|
||||
|
||||
| | on a node with the community package (node-b, node-d) |
|
||||
| | on a node with the community package (g14, shanks) |
|
||||
|---|---|
|
||||
| the package | `betterbird-bin` removed (quit Betterbird first) |
|
||||
| `/opt/betterbird` | the mesh's 153.4.0esr-bb10, in the same place, so the same profile opens |
|
||||
|
||||
@@ -412,13 +412,13 @@ func TestKeepProposesANodeLayerThroughTheController(t *testing.T) {
|
||||
f.write(profileDir+"/prefs.js", `user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("mail.spam.manualMark", true);`+"\n")
|
||||
oldNode, oldAsk := nodeName, asker
|
||||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||||
nodeName = func() string { return "node-d" }
|
||||
nodeName = func() string { return "shanks" }
|
||||
var asked []map[string]any
|
||||
asker = func(v string, a any) (json.RawMessage, error) {
|
||||
args := a.(map[string]any)
|
||||
asked = append(asked, args)
|
||||
if args["propose"] == nil {
|
||||
return json.RawMessage(`{"ok": true, "output": "{\"prefs\": {\"calendar.alarms.playsound\": false}, \"identities\": {\"mail\": {\"account\": \"mail\"}}}\n set at the terminal\nbetterbird on node-d, every value"}`), nil
|
||||
return json.RawMessage(`{"ok": true, "output": "{\"prefs\": {\"calendar.alarms.playsound\": false}, \"identities\": {\"mail\": {\"account\": \"mail\"}}}\n set at the terminal\nbetterbird on shanks, every value"}`), nil
|
||||
}
|
||||
return json.RawMessage(`{"ok": true, "output": "proposed: p-1"}`), nil
|
||||
}
|
||||
@@ -427,7 +427,7 @@ func TestKeepProposesANodeLayerThroughTheController(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(asked) != 2 || asked[0]["node"] != "node-d" || asked[1]["node"] != "node-d" || asked[1]["propose"] != "true" ||
|
||||
if len(asked) != 2 || asked[0]["node"] != "shanks" || asked[1]["node"] != "shanks" || asked[1]["propose"] != "true" ||
|
||||
asked[1]["module"] != "betterbird" {
|
||||
t.Fatalf("the node's layer read, then proposed: %v", asked)
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
// ADR 0329 §10, §12: derived from every desktop node's profile, laid out per layer, and proposed one layer at a
|
||||
// time, carrying the layer as it stands.
|
||||
|
||||
// derivingMesh is two desktop nodes, node-b (this one, f) and node-d (f2), and a server that holds no Betterbird, as the
|
||||
// derivingMesh is two desktop nodes, g14 (this one, f) and shanks (f2), and a server that holds no Betterbird, as the
|
||||
// controller answers them; layers are the controller's layers, open the proposals that wait.
|
||||
type derivingMesh struct {
|
||||
t *testing.T
|
||||
nodeD *fake
|
||||
shanks *fake
|
||||
layers map[string]map[string]any
|
||||
open bool
|
||||
proposed []map[string]any
|
||||
@@ -35,17 +35,17 @@ func (d *derivingMesh) ask(key string, body any) (json.RawMessage, error) {
|
||||
}
|
||||
switch {
|
||||
case key == "seat:mesh-controller.nodes":
|
||||
return answer(true, `[{"name": "node-b"}, {"name": "node-c"}, {"name": "node-d"}]`)
|
||||
return answer(true, `[{"name": "g14"}, {"name": "novox"}, {"name": "shanks"}]`)
|
||||
case key == "seat:mesh-controller.node":
|
||||
if args["node"] == "node-c" {
|
||||
return answer(true, "node-c\n assigned: postgres, nats\n")
|
||||
if args["node"] == "novox" {
|
||||
return answer(true, "novox\n assigned: postgres, nats\n")
|
||||
}
|
||||
return answer(true, fmt.Sprintf("%s\n last heard from here\n assigned: adwaita, betterbird, dunst\n", args["node"]))
|
||||
case key == "betterbird.betterbird_derive@node-d":
|
||||
case key == "betterbird.betterbird_derive@shanks":
|
||||
if d.asleep {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
here, err := d.nodeD.deriveHere(stringsOf(args["kinds"]))
|
||||
here, err := d.shanks.deriveHere(stringsOf(args["kinds"]))
|
||||
if err != nil {
|
||||
d.t.Fatal(err)
|
||||
}
|
||||
@@ -53,10 +53,10 @@ func (d *derivingMesh) ask(key string, body any) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"content": []any{map[string]any{"type": "text", "text": string(text)}}})
|
||||
case key == "seat:mesh-controller.settings" && args["proposals"] == "true":
|
||||
if d.open {
|
||||
return answer(true, "sab12 set betterbird on the whole mesh (accounts, identities)\n by node-b.node-tools at 2026-10-13 01:00; fingerprint ab12\n waiting for the operator until 2026-10-14 01:00\n"+
|
||||
"scd34 set mounts on node-d (sources)\n by node-b.node-tools at 2026-10-11 00:05; fingerprint dd0b\n chosen: done\n")
|
||||
return answer(true, "sab12 set betterbird on the whole mesh (accounts, identities)\n by g14.node-tools at 2026-10-13 01:00; fingerprint ab12\n waiting for the operator until 2026-10-14 01:00\n"+
|
||||
"scd34 set mounts on shanks (sources)\n by g14.node-tools at 2026-10-11 00:05; fingerprint dd0b\n chosen: done\n")
|
||||
}
|
||||
return answer(true, "scd34 set mounts on node-d (sources)\n by node-b.node-tools; fingerprint dd0b\n chosen: done\n")
|
||||
return answer(true, "scd34 set mounts on shanks (sources)\n by g14.node-tools; fingerprint dd0b\n chosen: done\n")
|
||||
case key == "seat:mesh-controller.settings" && args["propose"] == "true":
|
||||
d.proposed = append(d.proposed, args)
|
||||
return answer(true, "proposal sab12: Set the settings of betterbird?\n fingerprint ab12")
|
||||
@@ -107,23 +107,23 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
|
||||
f2 := newFake(t)
|
||||
f2.profileWithAccounts()
|
||||
f2.profileWithConfiguration()
|
||||
// node-d holds the same accounts and calendars, another signature, another tag colour and a calendar of its own.
|
||||
// shanks holds the same accounts and calendars, another signature, another tag colour and a calendar of its own.
|
||||
f2.write(profileDir+"/prefs.js", strings.NewReplacer(`"Regards"`, `"Cheers"`, `"#FF0000"`, `"#CC0000"`).Replace(f2.read(profileDir+"/prefs.js"))+
|
||||
`user_pref("calendar.registry.s1.type", "caldav");`+"\n"+
|
||||
`user_pref("calendar.registry.s1.uri", "https://dav.example.org/cal/standup/");`+"\n"+
|
||||
`user_pref("calendar.registry.s1.name", "Standup");`+"\n")
|
||||
f2.declareAccounts(adoptable)
|
||||
mesh := &derivingMesh{t: t, nodeD: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}}
|
||||
mesh := &derivingMesh{t: t, shanks: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}}
|
||||
oldNode, oldAsk := nodeName, asker
|
||||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||||
nodeName = func() string { return "node-b" }
|
||||
nodeName = func() string { return "g14" }
|
||||
asker = mesh.ask
|
||||
|
||||
a, err := f.Derive(nil, false, false, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(a.From, []string{"node-b", "node-d"}) || len(a.NotHeard) != 0 {
|
||||
if !reflect.DeepEqual(a.From, []string{"g14", "shanks"}) || len(a.NotHeard) != 0 {
|
||||
t.Fatalf("derived from the nodes holding Betterbird: %v %v", a.From, a.NotHeard)
|
||||
}
|
||||
whole := mustJSON(a)
|
||||
@@ -134,9 +134,9 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
|
||||
}
|
||||
cals, _ := mw[kindCalendars].(map[string]any)
|
||||
if cals["home"] == nil || cals["standup"] != nil {
|
||||
t.Errorf("a calendar both hold is mesh-wide, one only node-d holds is not: %v", cals)
|
||||
t.Errorf("a calendar both hold is mesh-wide, one only shanks holds is not: %v", cals)
|
||||
}
|
||||
if sh := mustJSON(a.Layers["node-d"]); !strings.Contains(sh, "https://dav.example.org/cal/standup/") {
|
||||
if sh := mustJSON(a.Layers["shanks"]); !strings.Contains(sh, "https://dav.example.org/cal/standup/") {
|
||||
t.Errorf("an entry on one node only goes into that node's layer: %s", sh)
|
||||
}
|
||||
tags, _ := mw[kindTags].(map[string]any)
|
||||
@@ -147,8 +147,8 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
|
||||
for _, d := range a.Differences {
|
||||
diffs[d.Kind+"/"+d.Entry+"/"+d.Field] = d
|
||||
}
|
||||
if d := diffs["identities/mail/signature"]; d.Node != "node-d" || mustJSON(d.There) != `["Cheers"]` {
|
||||
t.Errorf("the answer lists the field node-d holds differently: %+v", a.Differences)
|
||||
if d := diffs["identities/mail/signature"]; d.Node != "shanks" || mustJSON(d.There) != `["Cheers"]` {
|
||||
t.Errorf("the answer lists the field shanks holds differently: %+v", a.Differences)
|
||||
}
|
||||
if d := diffs["tags/$label1/color"]; d.There != "#CC0000" || d.MeshWide != "#FF0000" {
|
||||
t.Errorf("%+v", a.Differences)
|
||||
@@ -195,22 +195,22 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
|
||||
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "sab12 (the whole mesh)") || len(mesh.proposed) != 1 {
|
||||
t.Fatalf("one open proposal of the module's at a time: %v", err)
|
||||
}
|
||||
// Approved: the mesh-wide layer holds it, so the next layer is node-d's own.
|
||||
// Approved: the mesh-wide layer holds it, so the next layer is shanks's own.
|
||||
mesh.open = false
|
||||
mesh.layers[""] = values
|
||||
a, err = f.Derive(nil, false, true, prefs, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "node-d" || a.Next != "node-d" ||
|
||||
if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "shanks" || a.Next != "shanks" ||
|
||||
!strings.Contains(mesh.proposed[1]["values"].(string), "standup") {
|
||||
t.Fatalf("then the node's own layer: %v", mesh.proposed)
|
||||
}
|
||||
var nodeD map[string]any
|
||||
if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &nodeD); err != nil {
|
||||
var shanks map[string]any
|
||||
if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &shanks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mesh.layers["node-d"] = nodeD
|
||||
mesh.layers["shanks"] = shanks
|
||||
if a, err = f.Derive(nil, false, true, prefs, ""); err != nil || len(mesh.proposed) != 2 || !strings.HasPrefix(a.Next, "none") {
|
||||
t.Fatalf("nothing left to propose: %v %s", err, a.Next)
|
||||
}
|
||||
@@ -240,10 +240,10 @@ func TestALayerThatCannotBeReadRefusesTheProposal(t *testing.T) {
|
||||
f2 := newFake(t)
|
||||
f2.profileWithAccounts()
|
||||
f2.declareAccounts(adoptable)
|
||||
mesh := &derivingMesh{t: t, nodeD: f2, garbled: true}
|
||||
mesh := &derivingMesh{t: t, shanks: f2, garbled: true}
|
||||
oldNode, oldAsk := nodeName, asker
|
||||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||||
nodeName = func() string { return "node-b" }
|
||||
nodeName = func() string { return "g14" }
|
||||
asker = mesh.ask
|
||||
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
|
||||
len(mesh.proposed) != 0 {
|
||||
@@ -257,7 +257,7 @@ func TestALayerThatCannotBeReadRefusesTheProposal(t *testing.T) {
|
||||
t.Fatal("an unreadable layer is an error, never an empty layer")
|
||||
}
|
||||
mesh.garbled = false
|
||||
if got, err := readLayer("node-d"); err != nil || len(got) != 0 {
|
||||
if got, err := readLayer("shanks"); err != nil || len(got) != 0 {
|
||||
t.Fatalf("no layer, in the controller's words, is an empty layer: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +107,7 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
|
||||
}
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
|
||||
for _, never := range []string{"/home/", "jochen", "http://", "mediahuis"} {
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http://", "mediahuis"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ through an injected runner and a fake root in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | node-b | node-d |
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same |
|
||||
| start | none: dex starts the applet from the package's entry, in the login session's scope; the tray icon with it | none on disk. **The applet running now came from the predecessor's window-manager line** (a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
|
||||
@@ -62,7 +62,7 @@ The workstations are already in the state this module describes.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
Nothing is required on either machine. On node-d, log out and in once, or run `blueman_restart`, and
|
||||
Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and
|
||||
the applet runs from its one start. `blueman_check` then answers `ok`.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
@@ -101,8 +101,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
|
||||
for _, never := range []string{"/home/", "jochen", "http", "password", "token"} {
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: node-b\n\tAlias: node-b\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
|
||||
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
|
||||
|
||||
func headphones(connected bool) string {
|
||||
c := "no"
|
||||
@@ -34,7 +34,7 @@ func headphones(connected bool) string {
|
||||
func TestControllerReadsShow(t *testing.T) {
|
||||
using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
|
||||
c, err := Controller()
|
||||
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "node-b" || !c.Powered || c.Discoverable || !c.Pairable {
|
||||
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) {
|
||||
Retention: jetstream.WorkQueuePolicy, Discard: jetstream.DiscardNew, MaxAge: 24 * time.Hour, MaxBytes: 8 << 20}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := process.Account{URL: url, Node: "node-b", Module: "brother", User: "node-b.brother." + DeviceProcessID, Password: "pw"}
|
||||
account := process.Account{URL: url, Node: "g14", Module: "brother", User: "g14.brother." + DeviceProcessID, Password: "pw"}
|
||||
if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(),
|
||||
AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -234,7 +234,7 @@ func TestAFetchedPDFIsStoredUnderItsHashAndPrinted(t *testing.T) {
|
||||
// A refusal inside the mesh is answered, and nothing is stored nor printed.
|
||||
fake.body = nil
|
||||
before := len(r.objects.keys())
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-node-d-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-shanks-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Errorf("a loopback address: error = %v", err)
|
||||
}
|
||||
if len(r.objects.keys()) != before || fake.body != nil {
|
||||
|
||||
@@ -160,7 +160,7 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"object": str("a stored document's key: a scan's key, as scan.added names it, or the key node-files.upload answered"),
|
||||
"path": str("an absolute path of a file on the machine node names"),
|
||||
"node": str("the machine path is on, such as node-b"),
|
||||
"node": str("the machine path is on, such as g14"),
|
||||
"url": str("an http or https address of a document or a page"),
|
||||
"ask": str("the ask id of an earlier print that was answered as started: alone, it reads that print's outcome"),
|
||||
"copies": num("1 to 99 (default 1)"),
|
||||
|
||||
@@ -56,15 +56,15 @@ func TestPrintTakesExactlyOneSource(t *testing.T) {
|
||||
for name, args := range map[string]map[string]any{
|
||||
"none": {},
|
||||
"only options": {"copies": float64(2)},
|
||||
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "node-b"},
|
||||
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "g14"},
|
||||
"object and url": {"object": key, "url": "https://example.com/a.pdf"},
|
||||
"path+node and url": {"path": "/home/ops/a.pdf", "node": "node-b", "url": "https://example.com/a.pdf"},
|
||||
"all three": {"object": key, "path": "/home/ops/a.pdf", "node": "node-b", "url": "https://example.com/a.pdf"},
|
||||
"path+node and url": {"path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
|
||||
"all three": {"object": key, "path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
|
||||
"path without node": {"path": "/home/ops/a.pdf"},
|
||||
"node without path": {"node": "node-b", "object": key},
|
||||
"node without path": {"node": "g14", "object": key},
|
||||
"the retired upload": {"upload": "u1"},
|
||||
"object and upload": {"object": key, "upload": "u1"},
|
||||
"path and its object": {"path": "/home/ops/a.pdf", "node": "node-b", "object": key},
|
||||
"path and its object": {"path": "/home/ops/a.pdf", "node": "g14", "object": key},
|
||||
} {
|
||||
_, err := r.call(t, "printer.print", args)
|
||||
if err == nil {
|
||||
@@ -82,7 +82,7 @@ func TestPrintTakesExactlyOneSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC2: with path and node node-b, printer.print asks node-b's node-files to upload the path into brother's bucket, and
|
||||
// 1598/AC2: with path and node g14, printer.print asks g14's node-files to upload the path into brother's bucket, and
|
||||
// queues a print ask that carries the returned key and the options: under 4 KiB, and no path, node or bytes.
|
||||
func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
|
||||
fake := newIPPFake(t, 31, 5)
|
||||
@@ -90,12 +90,12 @@ func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
|
||||
key := sha256Hex(doc)
|
||||
up := withUpload(t, key, nil)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc) // what node-b's upload stored
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "node-b", "copies": float64(2)})
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc) // what g14's upload stored
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14", "copies": float64(2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(up.seen()) != 1 || up.seen()[0] != "node-b|/home/ops/letter.pdf|brother-test" {
|
||||
if len(up.seen()) != 1 || up.seen()[0] != "g14|/home/ops/letter.pdf|brother-test" {
|
||||
t.Fatalf("uploads asked: %v", up.seen())
|
||||
}
|
||||
if len(r.bus.published) != 1 {
|
||||
@@ -114,7 +114,7 @@ func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["job-id"] != float64(31) || m["node"] != "node-b" || m["path"] != "/home/ops/letter.pdf" {
|
||||
if m["job-id"] != float64(31) || m["node"] != "g14" || m["path"] != "/home/ops/letter.pdf" {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
if _, sent := fake.request(t); string(sent) != string(doc) {
|
||||
@@ -127,7 +127,7 @@ func TestARefusedUploadIsAnsweredWithItsReasonAndNothingIsPrinted(t *testing.T)
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
up := withUpload(t, "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name: it is in .ssh, which holds an account's credentials"))
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
_, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "node-b"})
|
||||
_, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
|
||||
if err == nil || !strings.Contains(err.Error(), "is refused by name: it is in .ssh") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
@@ -277,7 +277,7 @@ func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
|
||||
t.Cleanup(func() { uploadVia = old })
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "node-b"})
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14"})
|
||||
if err != nil {
|
||||
t.Fatalf("a slow upload failed the call: %v", err)
|
||||
}
|
||||
@@ -292,7 +292,7 @@ func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rm := read.(map[string]any); rm["state"] == nil {
|
||||
if rm["job-id"] != float64(72) || rm["path"] != "/home/ops/letter.pdf" || rm["node"] != "node-b" {
|
||||
if rm["job-id"] != float64(72) || rm["path"] != "/home/ops/letter.pdf" || rm["node"] != "g14" {
|
||||
t.Errorf("the outcome = %v", rm)
|
||||
}
|
||||
break
|
||||
@@ -309,7 +309,7 @@ func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
return "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name")
|
||||
}
|
||||
got, _ = r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "node-b"})
|
||||
got, _ = r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
|
||||
id2, _ := got.(map[string]any)["ask"].(string)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"ask": id2}); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
|
||||
@@ -139,7 +139,7 @@ func newRig(t *testing.T, settings, password string) *printerRig {
|
||||
return nil
|
||||
},
|
||||
Answers: answers,
|
||||
Node: "node-d",
|
||||
Node: "shanks",
|
||||
Now: func() time.Time { return fixedNow },
|
||||
}
|
||||
bus := &fakeAskBus{worker: w, answers: answers}
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestTheDeviceAnswerCarriesALinkAndNoBytes(t *testing.T) {
|
||||
}
|
||||
|
||||
// The real store: the binding's key signs, for the binding's host and port, in the module's bucket.
|
||||
s3 := NewS3(StoreFacts{Scheme: "http", At: "node-c.internal", Port: "20001", Bucket: "mesh-node-d-brother", Region: "eu-west",
|
||||
s3 := NewS3(StoreFacts{Scheme: "http", At: "novox.internal", Port: "20001", Bucket: "mesh-shanks-brother", Region: "eu-west",
|
||||
Key: "binding-key", Secret: "binding-secret"})
|
||||
s3.Now = func() time.Time { return fixedNow }
|
||||
got, err := s3.Link("GET", "abc.pdf", 10*time.Minute)
|
||||
@@ -84,7 +84,7 @@ func TestTheDeviceAnswerCarriesALinkAndNoBytes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gu, _ := url.Parse(got)
|
||||
if gu.Host != "node-c.internal:20001" || gu.Path != "/mesh-node-d-brother/abc.pdf" ||
|
||||
if gu.Host != "novox.internal:20001" || gu.Path != "/mesh-shanks-brother/abc.pdf" ||
|
||||
!strings.HasPrefix(gu.Query().Get("X-Amz-Credential"), "binding-key/") || gu.Query().Get("X-Amz-Expires") != "600" {
|
||||
t.Errorf("link %s", got)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) {
|
||||
if kept, err := r.objects.Get(t.Context(), want+".pdf"); err != nil || !bytes.Equal(kept, pdf) {
|
||||
t.Errorf("the bucket does not hold the filed PDF under its sha256 (%v)", err)
|
||||
}
|
||||
if ev.body["bytes"] != len(pdf) || ev.body["node"] != "node-d" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" {
|
||||
if ev.body["bytes"] != len(pdf) || ev.body["node"] != "shanks" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" {
|
||||
t.Errorf("event = %v", ev.body)
|
||||
}
|
||||
if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want {
|
||||
|
||||
@@ -290,11 +290,11 @@ func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
var asked []string
|
||||
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
|
||||
Why: "the switch verb", By: "node-b.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
|
||||
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"node-b.node-tools"`} {
|
||||
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
|
||||
if !strings.Contains(done, want) {
|
||||
t.Fatalf("the line %s lacks %s", done, want)
|
||||
}
|
||||
|
||||
@@ -355,8 +355,8 @@ func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
|
||||
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"node-a", "node-b"}, nil })
|
||||
if err != nil || strings.Join(nodes, ",") != "node-a,node-b" {
|
||||
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
|
||||
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
|
||||
t.Fatalf("%v %v", nodes, err)
|
||||
}
|
||||
p, w := node(t, "laptop")
|
||||
|
||||
@@ -251,7 +251,7 @@ func TestTheSessionsGitIsPointedAtTheFileUnderTheOperatorsOwnEnvironment(t *test
|
||||
|
||||
func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
|
||||
for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test",
|
||||
"https://agent-anchor:t@git.example.test/acme/hq.git", "ssh://agent-anchor:t@git.example.test",
|
||||
"https://agent-anchor:t@git.example.test/novox/hq.git", "ssh://agent-anchor:t@git.example.test",
|
||||
"https://agent-anchor:t@git.example.test\nnot a line"} {
|
||||
if _, err := ForgeCredentialLines(bad); err == nil {
|
||||
t.Errorf("%q was taken", bad)
|
||||
|
||||
@@ -131,13 +131,13 @@ func TestWhatIsNotAWorkAroundPasses(t *testing.T) {
|
||||
g := guard()
|
||||
for _, command := range []string{
|
||||
"git push -u origin feat/x",
|
||||
"git clone ssh://git@git.anchor.internal:222/acme/hq.git",
|
||||
"git fetch ssh://git@git.anchor.internal:222/acme/hq.git main",
|
||||
"git clone ssh://git@git.anchor.internal:222/novox/hq.git",
|
||||
"git fetch ssh://git@git.anchor.internal:222/novox/hq.git main",
|
||||
`GIT_SSH_COMMAND="ssh -i ~/.ssh/forge -o IdentitiesOnly=yes" git push origin HEAD`,
|
||||
"git -c core.sshCommand='ssh -p 222' pull",
|
||||
"ssh -T git@git.anchor.internal -p 222",
|
||||
"ssh forge",
|
||||
"scp git@git.anchor.internal:acme/hq.git .",
|
||||
"scp git@git.anchor.internal:novox/hq.git .",
|
||||
"ssh github.com",
|
||||
"ssh -T git@github.com",
|
||||
"ssh user@203.0.113.7 uptime",
|
||||
|
||||
@@ -51,13 +51,13 @@ func suggestedCall(t *testing.T, msg string) map[string]any {
|
||||
|
||||
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
|
||||
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@node-b",
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
@@ -81,14 +81,14 @@ func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
|
||||
|
||||
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
|
||||
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
clock := t0
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@node-b",
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
@@ -114,20 +114,20 @@ func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
|
||||
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
|
||||
// regression here is caught the same way as the Stop hook's.
|
||||
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@node-b",
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
|
||||
if !strings.Contains(msg, "\"by\":\"fix-1757@node-b\"") {
|
||||
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
|
||||
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
|
||||
}
|
||||
args := suggestedCall(t, msg)
|
||||
|
||||
@@ -813,7 +813,7 @@ func (m *MemoryCarrier) unwritten(key, project, path string, err error) {
|
||||
}
|
||||
|
||||
// OlderBeside is where the older of two versions too large to share one file is kept: beside the file, named for
|
||||
// the node it was changed on ("MEMORY.md" from node-d: "MEMORY.older-from-node-d.md").
|
||||
// the node it was changed on ("MEMORY.md" from shanks: "MEMORY.older-from-shanks.md").
|
||||
func OlderBeside(path, node string) string {
|
||||
ext := filepath.Ext(path)
|
||||
return strings.TrimSuffix(path, ext) + ".older-from-" + node + ext
|
||||
@@ -900,7 +900,7 @@ func (m *MemoryCarrier) Take(key, op string, value json.RawMessage, revision uin
|
||||
}
|
||||
// Only noted here, never applied: the runtime hands the watch's values over one at a time and gives the whole
|
||||
// current set ten seconds, so a watch whose handler reads the state, waits for a look or writes a file never
|
||||
// starts on a node with a few hundred memory files (issue 367, seen on node-d on 2026-10-13). Drain applies
|
||||
// starts on a node with a few hundred memory files (issue 367, seen on shanks on 2026-10-13). Drain applies
|
||||
// what was noted, the newest revision of each key.
|
||||
m.queueMu.Lock()
|
||||
if m.queue == nil {
|
||||
|
||||
@@ -206,15 +206,15 @@ func holdsText(list []string, s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
const project = "-home-op-projects-acme-hq"
|
||||
const project = "-home-op-projects-novox-hq"
|
||||
|
||||
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
|
||||
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
||||
root := t.TempDir()
|
||||
return state, c, newMemNode(t, root, "node-b", HomeOperator, state, c), newMemNode(t, root, "node-d", HomeOperator, state, c)
|
||||
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
|
||||
}
|
||||
|
||||
// carried writes a file on node-b and carries it to node-d.
|
||||
// carried writes a file on g14 and carries it to shanks.
|
||||
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
|
||||
t.Helper()
|
||||
a.write(t, project, file, text, c.now)
|
||||
@@ -229,39 +229,39 @@ func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file,
|
||||
|
||||
// §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once.
|
||||
func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
nodeB.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
|
||||
nodeB.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
|
||||
nodeB.look(t)
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
|
||||
g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} {
|
||||
if got, ok := nodeD.read(t, project, file); !ok || got != want {
|
||||
t.Fatalf("%s on node-d: %q, %v", file, got, ok)
|
||||
if got, ok := shanks.read(t, project, file); !ok || got != want {
|
||||
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
|
||||
}
|
||||
}
|
||||
before := state.rev
|
||||
nodeD.look(t)
|
||||
nodeB.look(t)
|
||||
shanks.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if state.rev != before {
|
||||
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
|
||||
}
|
||||
// A change on node-d comes back to node-b, over the version it replaced, which node-b keeps.
|
||||
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
|
||||
c.now = c.now.Add(time.Minute)
|
||||
nodeD.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
|
||||
nodeD.look(t)
|
||||
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
if got, _ := nodeB.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
|
||||
t.Fatalf("the change did not reach node-b: %q", got)
|
||||
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
|
||||
t.Fatalf("the change did not reach g14: %q", got)
|
||||
}
|
||||
if !holdsText(nodeB.copies(t), "ADR 0343 proposed.\n") {
|
||||
t.Fatal("the version replaced on node-b was not kept")
|
||||
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
|
||||
t.Fatal("the version replaced on g14 was not kept")
|
||||
}
|
||||
if len(nodeB.notices)+len(nodeD.notices) != 0 {
|
||||
t.Fatalf("a plain change warned: %v %v", nodeB.notices, nodeD.notices)
|
||||
if len(g14.notices)+len(shanks.notices) != 0 {
|
||||
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
|
||||
}
|
||||
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
|
||||
raw, _ := json.Marshal(nodeB.Status())
|
||||
raw, _ := json.Marshal(g14.Status())
|
||||
if strings.Contains(string(raw), "accepted") {
|
||||
t.Fatalf("the memory tool answered the text: %s", raw)
|
||||
}
|
||||
@@ -270,44 +270,44 @@ func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
|
||||
// §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are
|
||||
// listed with why.
|
||||
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
projectDir := filepath.Join(nodeB.Home, ".claude", "projects", project)
|
||||
nodeB.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
|
||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||
for path, text := range map[string]string{
|
||||
filepath.Join(projectDir, "session.jsonl"): "{}\n",
|
||||
filepath.Join(nodeB.Home, ".claude", "settings.json"): "{}\n",
|
||||
filepath.Join(nodeB.Home, ".claude", "history.jsonl"): "{}\n",
|
||||
filepath.Join(projectDir, "session.jsonl"): "{}\n",
|
||||
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
|
||||
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
nodeB.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
|
||||
nodeB.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
|
||||
nodeB.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
|
||||
nodeB.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
|
||||
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
|
||||
g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
|
||||
g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
|
||||
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere.md")
|
||||
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
|
||||
if err := os.Symlink(outside, nodeB.path(project, "link.md")); err != nil {
|
||||
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodeB.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
||||
if _, ok := nodeD.read(t, project, file); ok {
|
||||
if _, ok := shanks.read(t, project, file); ok {
|
||||
t.Errorf("%s was carried", file)
|
||||
}
|
||||
}
|
||||
for _, file := range []string{"MEMORY.md", "hash.md"} {
|
||||
if _, ok := nodeD.read(t, project, file); !ok {
|
||||
if _, ok := shanks.read(t, project, file); !ok {
|
||||
t.Errorf("%s was not carried", file)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(nodeD.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
|
||||
t.Error("a session was carried")
|
||||
}
|
||||
for _, f := range []string{"settings.json", "history.jsonl"} {
|
||||
if _, err := os.Stat(filepath.Join(nodeD.Home, ".claude", f)); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
|
||||
t.Errorf("%s was carried", f)
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
|
||||
}
|
||||
not, _ := nodeB.Status()["not carried"].(map[string]string)
|
||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
||||
if not[project+"/"+file] == "" {
|
||||
t.Errorf("%s is not listed as not carried: %v", file, not)
|
||||
@@ -327,8 +327,8 @@ func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
||||
func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
|
||||
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
||||
root := t.TempDir()
|
||||
laptop := newMemNode(t, root, "node-b", HomeOperator, state, c)
|
||||
control := newMemNode(t, root, "node-c", HomeAgent, state, c)
|
||||
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
|
||||
control := newMemNode(t, root, "novox", HomeAgent, state, c)
|
||||
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
|
||||
control.look(t)
|
||||
state.pump(t)
|
||||
@@ -343,7 +343,7 @@ func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
|
||||
}
|
||||
// A value of the other kind put under this kind's key is refused too.
|
||||
key := MemoryKey(HomeOperator, project, "x.md")
|
||||
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "node-c"})
|
||||
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
|
||||
if err := laptop.Take(key, "put", raw, 99); err == nil {
|
||||
t.Fatal("a value of another kind was taken")
|
||||
}
|
||||
@@ -362,31 +362,31 @@ func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
|
||||
// §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with
|
||||
// one warning; it clears once the file is tidied.
|
||||
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "- base\n")
|
||||
nodeB.write(t, project, "MEMORY.md", "- base\n- learned on node-b\n", c.now.Add(time.Minute))
|
||||
nodeD.write(t, project, "MEMORY.md", "- base\n- learned on node-d\n", c.now.Add(2*time.Minute))
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
|
||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
|
||||
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute))
|
||||
c.now = c.now.Add(3 * time.Minute)
|
||||
nodeB.look(t)
|
||||
nodeD.look(t)
|
||||
g14.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
nodeB.look(t)
|
||||
nodeD.look(t)
|
||||
g14.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
a, _ := nodeB.read(t, project, "MEMORY.md")
|
||||
b, _ := nodeD.read(t, project, "MEMORY.md")
|
||||
a, _ := g14.read(t, project, "MEMORY.md")
|
||||
b, _ := shanks.read(t, project, "MEMORY.md")
|
||||
if a != b {
|
||||
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b)
|
||||
}
|
||||
newer, older := strings.Index(a, "learned on node-d"), strings.Index(a, "learned on node-b")
|
||||
newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14")
|
||||
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
|
||||
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
|
||||
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
|
||||
}
|
||||
if !strings.Contains(a[marker:], "changed on node-d") || !strings.Contains(a[marker:], "changed on node-b") {
|
||||
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
|
||||
t.Fatalf("the line does not name both nodes:\n%s", a)
|
||||
}
|
||||
notices := append(append([]map[string]any{}, nodeB.notices...), nodeD.notices...)
|
||||
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
||||
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
|
||||
t.Fatalf("not one warning: %v", notices)
|
||||
}
|
||||
@@ -406,22 +406,22 @@ func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T
|
||||
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
|
||||
t.Errorf("the headline is longer than sixty characters: %q", h)
|
||||
}
|
||||
all := append(nodeB.copies(t), nodeD.copies(t)...)
|
||||
for _, v := range []string{"- base\n- learned on node-b\n", "- base\n- learned on node-d\n"} {
|
||||
all := append(g14.copies(t), shanks.copies(t)...)
|
||||
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
|
||||
if !holdsText(all, v) {
|
||||
t.Errorf("the version %q was not kept", v)
|
||||
}
|
||||
}
|
||||
if st := nodeB.Status(); len(st["conflicts"].([]conflict))+len(nodeD.Status()["conflicts"].([]conflict)) != 1 {
|
||||
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
|
||||
t.Fatalf("the conflict is not listed: %v", st)
|
||||
}
|
||||
// Tidied on either node, the warning clears.
|
||||
c.now = c.now.Add(time.Minute)
|
||||
nodeB.write(t, project, "MEMORY.md", "- base\n- learned on node-b\n- learned on node-d\n", c.now)
|
||||
nodeB.look(t)
|
||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
cleared := false
|
||||
for _, n := range append(nodeB.notices, nodeD.notices...) {
|
||||
for _, n := range append(g14.notices, shanks.notices...) {
|
||||
if n["clear"] == true {
|
||||
cleared = true
|
||||
}
|
||||
@@ -429,39 +429,39 @@ func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T
|
||||
if !cleared {
|
||||
t.Fatal("the warning did not clear once the file was tidied")
|
||||
}
|
||||
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "- base\n- learned on node-b\n- learned on node-d\n" {
|
||||
t.Fatalf("the tidied file did not reach node-d: %q", got)
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
|
||||
t.Fatalf("the tidied file did not reach shanks: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The order of the two versions: the later change first, equal times by node name.
|
||||
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
|
||||
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
m := string(MergeVersions([]byte("old\n"), "node-b", at, []byte("new\n"), "node-d", at.Add(time.Second)))
|
||||
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
|
||||
if strings.Index(m, "new") > strings.Index(m, "old") {
|
||||
t.Fatalf("the newer is not first: %s", m)
|
||||
}
|
||||
m = string(MergeVersions([]byte("from node-d\n"), "node-d", at, []byte("from node-a\n"), "node-a", at))
|
||||
if strings.Index(m, "from node-a") > strings.Index(m, "from node-d") {
|
||||
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
|
||||
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
|
||||
t.Fatalf("equal times are not ordered by node name: %s", m)
|
||||
}
|
||||
}
|
||||
|
||||
// §9: a removal moves the other node's unchanged file into the copies.
|
||||
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "old.md", "an old note\n")
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "index\n")
|
||||
if err := os.Remove(nodeB.path(project, "old.md")); err != nil {
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
|
||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
||||
if err := os.Remove(g14.path(project, "old.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodeB.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if _, ok := nodeD.read(t, project, "old.md"); ok {
|
||||
t.Fatal("the removal did not reach node-d")
|
||||
if _, ok := shanks.read(t, project, "old.md"); ok {
|
||||
t.Fatal("the removal did not reach shanks")
|
||||
}
|
||||
if !holdsText(nodeD.copies(t), "an old note\n") {
|
||||
t.Fatal("the removed version was not kept on node-d")
|
||||
if !holdsText(shanks.copies(t), "an old note\n") {
|
||||
t.Fatal("the removed version was not kept on shanks")
|
||||
}
|
||||
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
|
||||
var v MemoryVersion
|
||||
@@ -469,7 +469,7 @@ func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
||||
if !ok || !v.Removed || v.Text != "" {
|
||||
t.Fatalf("the removal is not a marker in the state: %s", raw)
|
||||
}
|
||||
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatal("another file was touched")
|
||||
}
|
||||
}
|
||||
@@ -477,23 +477,23 @@ func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
||||
// §9: a change outlives a removal, whichever reaches the state first.
|
||||
func TestAChangeOutlivesARemoval(t *testing.T) {
|
||||
for _, removalFirst := range []bool{true, false} {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "note.md", "v1\n")
|
||||
_ = os.Remove(nodeB.path(project, "note.md"))
|
||||
nodeD.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
||||
_ = os.Remove(g14.path(project, "note.md"))
|
||||
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
||||
if removalFirst {
|
||||
nodeB.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
nodeD.look(t)
|
||||
shanks.look(t)
|
||||
} else {
|
||||
nodeD.look(t)
|
||||
nodeB.look(t)
|
||||
shanks.look(t)
|
||||
g14.look(t)
|
||||
}
|
||||
state.pump(t)
|
||||
nodeB.look(t)
|
||||
nodeD.look(t)
|
||||
g14.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
for _, n := range []*memNode{nodeB, nodeD} {
|
||||
for _, n := range []*memNode{g14, shanks} {
|
||||
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
|
||||
t.Fatalf("removal first %v: the change did not outlive the removal on %s: %q", removalFirst, n.Node, got)
|
||||
}
|
||||
@@ -503,51 +503,51 @@ func TestAChangeOutlivesARemoval(t *testing.T) {
|
||||
|
||||
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
|
||||
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "index\n")
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
||||
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
||||
// The folder gone on node-b.
|
||||
if err := os.RemoveAll(filepath.Join(nodeB.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||
// The folder gone on g14.
|
||||
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodeB.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatal("a missing folder removed the file elsewhere")
|
||||
}
|
||||
// A deleted key.
|
||||
_ = state.Delete(key)
|
||||
state.pump(t)
|
||||
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatal("a deleted key removed the file")
|
||||
}
|
||||
// The state lost: the file is carried again, not removed.
|
||||
nodeD.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
if _, _, ok, _ := state.Get(key); !ok {
|
||||
t.Fatal("a file whose key the state lost was not carried again")
|
||||
}
|
||||
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatalf("the file was touched on node-d: %q", got)
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatalf("the file was touched on shanks: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
|
||||
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
nodeB.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
|
||||
nodeB.write(t, project, "laptop.md", "only on the laptop\n", c.now)
|
||||
nodeD.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
|
||||
nodeD.write(t, project, "workstation.md", "only on the workstation\n", c.now)
|
||||
nodeD.write(t, project, "same.md", "same\n", c.now)
|
||||
nodeB.write(t, project, "same.md", "same\n", c.now)
|
||||
nodeB.look(t)
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
|
||||
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
|
||||
shanks.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
|
||||
shanks.write(t, project, "workstation.md", "only on the workstation\n", c.now)
|
||||
shanks.write(t, project, "same.md", "same\n", c.now)
|
||||
g14.write(t, project, "same.md", "same\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
nodeD.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
nodeB.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
for _, n := range []*memNode{nodeB, nodeD} {
|
||||
for _, n := range []*memNode{g14, shanks} {
|
||||
index, _ := n.read(t, project, "MEMORY.md")
|
||||
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
|
||||
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
|
||||
@@ -559,23 +559,23 @@ func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := len(nodeB.notices) + len(nodeD.notices); n != 1 {
|
||||
if n := len(g14.notices) + len(shanks.notices); n != 1 {
|
||||
t.Fatalf("%d warnings for one conflict", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
|
||||
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
elsewhere := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(nodeD.Home, ".claude", "projects", project), 0o700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(elsewhere, filepath.Join(nodeD.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodeB.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||
nodeB.look(t)
|
||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
|
||||
t.Fatalf("written through a symbolic link: %v", entries)
|
||||
@@ -619,24 +619,24 @@ func (s *memState) replay(t *testing.T, n *memNode) {
|
||||
// §6: a node that was away while a file changed twice takes the newest version, whose base it never saw, because
|
||||
// its own file holds no change it did not carry; the version replaced is kept.
|
||||
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "v1\n")
|
||||
state.watchers = []*MemoryCarrier{nodeB.MemoryCarrier}
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n")
|
||||
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
||||
for _, v := range []string{"v2\n", "v3\n"} {
|
||||
c.now = c.now.Add(time.Minute)
|
||||
nodeB.write(t, project, "MEMORY.md", v, c.now)
|
||||
nodeB.look(t)
|
||||
g14.write(t, project, "MEMORY.md", v, c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
}
|
||||
state.watchers = append(state.watchers, nodeD.MemoryCarrier)
|
||||
state.replay(t, nodeD)
|
||||
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "v3\n" {
|
||||
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
||||
state.replay(t, shanks)
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" {
|
||||
t.Fatalf("the node that was away holds %q", got)
|
||||
}
|
||||
if !holdsText(nodeD.copies(t), "v1\n") {
|
||||
if !holdsText(shanks.copies(t), "v1\n") {
|
||||
t.Fatal("the version replaced was not kept")
|
||||
}
|
||||
if len(nodeB.notices)+len(nodeD.notices) != 0 {
|
||||
if len(g14.notices)+len(shanks.notices) != 0 {
|
||||
t.Fatal("taking a newer version warned")
|
||||
}
|
||||
}
|
||||
@@ -644,24 +644,24 @@ func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
|
||||
// §2, §7: two versions too large to share one file are kept in two: the newer in the file, the older beside it,
|
||||
// both carried; and a text that would not fit in one value of the state is not carried.
|
||||
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "big.md", "base\n")
|
||||
older := strings.Repeat("from node-b ", MemoryMostBytes/12-100) + "\n"
|
||||
newer := strings.Repeat("from node-d ", MemoryMostBytes/12-100) + "\n"
|
||||
nodeB.write(t, project, "big.md", older, c.now.Add(time.Minute))
|
||||
nodeD.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n")
|
||||
older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n"
|
||||
newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n"
|
||||
g14.write(t, project, "big.md", older, c.now.Add(time.Minute))
|
||||
shanks.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
|
||||
c.now = c.now.Add(3 * time.Minute)
|
||||
nodeB.look(t)
|
||||
nodeD.look(t)
|
||||
g14.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
nodeB.look(t)
|
||||
nodeD.look(t)
|
||||
g14.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
beside := OlderBeside("big.md", "node-b")
|
||||
if beside != "big.older-from-node-b.md" {
|
||||
beside := OlderBeside("big.md", "g14")
|
||||
if beside != "big.older-from-g14.md" {
|
||||
t.Fatalf("the older is named %q", beside)
|
||||
}
|
||||
for _, n := range []*memNode{nodeB, nodeD} {
|
||||
for _, n := range []*memNode{g14, shanks} {
|
||||
if got, _ := n.read(t, project, "big.md"); got != newer {
|
||||
t.Fatalf("%s's file does not hold the newer version", n.Node)
|
||||
}
|
||||
@@ -669,18 +669,18 @@ func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
|
||||
t.Fatalf("%s does not hold the older version beside it", n.Node)
|
||||
}
|
||||
}
|
||||
notices := append(append([]map[string]any{}, nodeB.notices...), nodeD.notices...)
|
||||
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
||||
if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") {
|
||||
t.Fatalf("not one warning saying the older is beside it: %v", notices)
|
||||
}
|
||||
// Under the size limit, but too large once encoded: not carried, and said.
|
||||
nodeB.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
|
||||
nodeB.look(t)
|
||||
g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if _, ok := nodeD.read(t, project, "angles.md"); ok {
|
||||
if _, ok := shanks.read(t, project, "angles.md"); ok {
|
||||
t.Fatal("a text too large for one value was carried")
|
||||
}
|
||||
not, _ := nodeB.Status()["not carried"].(map[string]string)
|
||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
||||
if not[project+"/angles.md"] == "" {
|
||||
t.Fatalf("it is not listed as not carried: %v", not)
|
||||
}
|
||||
@@ -713,8 +713,8 @@ func (s *memState) putRaw(t *testing.T, v MemoryVersion) string {
|
||||
// Review of #276, 1: a value whose project or path leads out of a memory folder is never taken, and nothing is
|
||||
// written for it.
|
||||
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
root := filepath.Dir(filepath.Dir(nodeD.Home))
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
root := filepath.Dir(filepath.Dir(shanks.Home))
|
||||
for _, bad := range []struct{ project, path string }{
|
||||
{"../../../escaped", "x.md"}, {"..", "x.md"}, {"a/b", "x.md"}, {"", "x.md"},
|
||||
{project, "../x.md"}, {project, "/abs.md"}, {project, "a//x.md"}, {project, "./x.md"}, {project, "a/../../x.md"},
|
||||
@@ -727,8 +727,8 @@ func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
state.pump(t)
|
||||
nodeB.look(t)
|
||||
nodeD.look(t)
|
||||
g14.look(t)
|
||||
shanks.look(t)
|
||||
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() {
|
||||
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
|
||||
@@ -748,21 +748,21 @@ func TestWhatCannotBeReadIsNeverTakenForGone(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root reads a file whatever its mode")
|
||||
}
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "index\n")
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "notes/deep.md", "deep\n")
|
||||
file, folder := nodeB.path(project, "MEMORY.md"), filepath.Dir(nodeB.path(project, "notes/deep.md"))
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
||||
carriedOnBoth(t, state, c, g14, shanks, "notes/deep.md", "deep\n")
|
||||
file, folder := g14.path(project, "MEMORY.md"), filepath.Dir(g14.path(project, "notes/deep.md"))
|
||||
_ = os.Chmod(file, 0)
|
||||
_ = os.Chmod(folder, 0)
|
||||
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
|
||||
nodeB.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
|
||||
if _, ok := nodeD.read(t, project, f); !ok {
|
||||
t.Errorf("%s, unreadable on node-b, was removed on node-d", f)
|
||||
if _, ok := shanks.read(t, project, f); !ok {
|
||||
t.Errorf("%s, unreadable on g14, was removed on shanks", f)
|
||||
}
|
||||
}
|
||||
not, _ := nodeB.Status()["not carried"].(map[string]string)
|
||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
||||
if len(not) == 0 {
|
||||
t.Fatalf("nothing is said as not carried: %v", not)
|
||||
}
|
||||
@@ -782,33 +782,33 @@ func TestNoUnscreenedTextReachesTheState(t *testing.T) {
|
||||
token := "ghp_" + strings.Repeat("A1b2C3d4", 5) + "\n"
|
||||
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
|
||||
for _, text := range []string{token, big} {
|
||||
// The removal path: removed on node-b, changed on node-d to what is never carried.
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "note.md", "v1\n")
|
||||
_ = os.Remove(nodeB.path(project, "note.md"))
|
||||
nodeD.write(t, project, "note.md", text, c.now.Add(time.Minute))
|
||||
nodeB.look(t)
|
||||
// The removal path: removed on g14, changed on shanks to what is never carried.
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
||||
_ = os.Remove(g14.path(project, "note.md"))
|
||||
shanks.write(t, project, "note.md", text, c.now.Add(time.Minute))
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if state.holds(text[:20]) {
|
||||
t.Errorf("the removal path put text that is never carried into the state")
|
||||
}
|
||||
if got, _ := nodeD.read(t, project, "note.md"); got != text {
|
||||
t.Errorf("the change on node-d was not kept")
|
||||
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
||||
t.Errorf("the change on shanks was not kept")
|
||||
}
|
||||
// The conflict path: changed on node-b, and on node-d to what is never carried.
|
||||
state, c, nodeB, nodeD = twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "note.md", "v1\n")
|
||||
nodeB.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
||||
nodeD.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
|
||||
nodeB.look(t)
|
||||
// The conflict path: changed on g14, and on shanks to what is never carried.
|
||||
state, c, g14, shanks = twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
||||
g14.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
||||
shanks.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
nodeD.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
if state.holds(text[:20]) {
|
||||
t.Errorf("the conflict path put text that is never carried into the state")
|
||||
}
|
||||
if got, _ := nodeD.read(t, project, "note.md"); got != text {
|
||||
t.Errorf("node-d's own version was not kept as it was")
|
||||
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
||||
t.Errorf("shanks's own version was not kept as it was")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,24 +819,24 @@ func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root writes a folder whatever its mode")
|
||||
}
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "base\n")
|
||||
nodeB.write(t, project, "MEMORY.md", "base\nfrom node-b\n", c.now.Add(time.Minute))
|
||||
nodeD.write(t, project, "MEMORY.md", "base\nfrom node-d\n", c.now.Add(2*time.Minute))
|
||||
folder := filepath.Dir(nodeD.path(project, "MEMORY.md"))
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "base\n")
|
||||
g14.write(t, project, "MEMORY.md", "base\nfrom g14\n", c.now.Add(time.Minute))
|
||||
shanks.write(t, project, "MEMORY.md", "base\nfrom shanks\n", c.now.Add(2*time.Minute))
|
||||
folder := filepath.Dir(shanks.path(project, "MEMORY.md"))
|
||||
_ = os.Chmod(folder, 0o500)
|
||||
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
|
||||
nodeB.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = nodeD.Look()
|
||||
_ = shanks.Look()
|
||||
state.pump(t)
|
||||
}
|
||||
if state.holds("<!-- novox mesh") {
|
||||
t.Fatal("a merge that could not be written here reached the state")
|
||||
}
|
||||
unwritten := 0
|
||||
for _, n := range nodeD.notices {
|
||||
for _, n := range shanks.notices {
|
||||
if strings.HasSuffix(n["key"].(string), ".unwritten") {
|
||||
unwritten++
|
||||
}
|
||||
@@ -845,13 +845,13 @@ func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
|
||||
t.Fatalf("%d warnings that the file could not be written", unwritten)
|
||||
}
|
||||
_ = os.Chmod(folder, 0o700)
|
||||
nodeD.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
nodeB.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
for _, n := range []*memNode{nodeB, nodeD} {
|
||||
for _, n := range []*memNode{g14, shanks} {
|
||||
got, _ := n.read(t, project, "MEMORY.md")
|
||||
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from node-b") || !strings.Contains(got, "from node-d") {
|
||||
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from g14") || !strings.Contains(got, "from shanks") {
|
||||
t.Fatalf("%s does not hold both versions once:\n%s", n.Node, got)
|
||||
}
|
||||
}
|
||||
@@ -860,24 +860,24 @@ func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
|
||||
// Review of #276, minor: a file that changed between its reading and its writing is not written over; text that is
|
||||
// not UTF-8 is not carried.
|
||||
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
_ = state
|
||||
nodeB.write(t, project, "a.md", "read\n", c.now)
|
||||
nodeB.write(t, project, "a.md", "changed meanwhile\n", c.now)
|
||||
err := nodeB.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
|
||||
g14.write(t, project, "a.md", "read\n", c.now)
|
||||
g14.write(t, project, "a.md", "changed meanwhile\n", c.now)
|
||||
err := g14.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
|
||||
if !errors.Is(err, errChangedHere) {
|
||||
t.Fatalf("written over a change: %v", err)
|
||||
}
|
||||
if got, _ := nodeB.read(t, project, "a.md"); got != "changed meanwhile\n" {
|
||||
if got, _ := g14.read(t, project, "a.md"); got != "changed meanwhile\n" {
|
||||
t.Fatalf("the change was replaced: %q", got)
|
||||
}
|
||||
nodeB.write(t, project, "latin1.md", "caf\xe9\n", c.now)
|
||||
nodeB.look(t)
|
||||
g14.write(t, project, "latin1.md", "caf\xe9\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if _, ok := nodeD.read(t, project, "latin1.md"); ok {
|
||||
if _, ok := shanks.read(t, project, "latin1.md"); ok {
|
||||
t.Fatal("text that is not UTF-8 was carried")
|
||||
}
|
||||
not, _ := nodeB.Status()["not carried"].(map[string]string)
|
||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
||||
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
|
||||
t.Fatalf("it is not said why: %v", not)
|
||||
}
|
||||
@@ -886,28 +886,28 @@ func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
|
||||
// §9: a node that was away takes a removal of a version newer than the one it holds, when it holds no change of
|
||||
// its own: the removed file does not come back.
|
||||
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, nodeB, nodeD, "old.md", "v1\n")
|
||||
state.watchers = []*MemoryCarrier{nodeB.MemoryCarrier}
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "old.md", "v1\n")
|
||||
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
||||
c.now = c.now.Add(time.Minute)
|
||||
nodeB.write(t, project, "old.md", "v2\n", c.now)
|
||||
nodeB.look(t)
|
||||
g14.write(t, project, "old.md", "v2\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
_ = os.Remove(nodeB.path(project, "old.md"))
|
||||
nodeB.look(t)
|
||||
_ = os.Remove(g14.path(project, "old.md"))
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
state.watchers = append(state.watchers, nodeD.MemoryCarrier)
|
||||
state.replay(t, nodeD)
|
||||
nodeD.look(t)
|
||||
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
||||
state.replay(t, shanks)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
nodeB.look(t)
|
||||
if _, ok := nodeD.read(t, project, "old.md"); ok {
|
||||
g14.look(t)
|
||||
if _, ok := shanks.read(t, project, "old.md"); ok {
|
||||
t.Fatal("the removal did not reach the node that was away")
|
||||
}
|
||||
if _, ok := nodeB.read(t, project, "old.md"); ok {
|
||||
if _, ok := g14.read(t, project, "old.md"); ok {
|
||||
t.Fatal("the removed file came back")
|
||||
}
|
||||
if !holdsText(nodeD.copies(t), "v1\n") {
|
||||
if !holdsText(shanks.copies(t), "v1\n") {
|
||||
t.Fatal("the version removed on the node that was away was not kept")
|
||||
}
|
||||
}
|
||||
@@ -924,25 +924,25 @@ func (s slowState) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
}
|
||||
|
||||
// Issue 367, seen live: the runtime hands a watch its current values one at a time and gives them all ten seconds;
|
||||
// node-d's handler read the state and waited for its look for each of a few hundred values, so the watch never
|
||||
// started and nothing written on node-b reached it. The handler now only notes a change: it returns at once while a
|
||||
// shanks's handler read the state and waited for its look for each of a few hundred values, so the watch never
|
||||
// started and nothing written on g14 reached it. The handler now only notes a change: it returns at once while a
|
||||
// look holds the carrier and while the state is slow to answer, and Drain applies what it noted.
|
||||
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
|
||||
state, c, nodeB, nodeD := twoNodes(t)
|
||||
nodeB.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||
nodeB.look(t)
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||
g14.look(t)
|
||||
state.mu.Lock()
|
||||
state.pending = nil
|
||||
state.mu.Unlock()
|
||||
slow := slowState{memState: state, gate: make(chan struct{})}
|
||||
nodeD.Store = slow
|
||||
shanks.Store = slow
|
||||
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
||||
raw, rev, _, _ := state.Get(key)
|
||||
nodeD.mu.Lock() // a look running
|
||||
shanks.mu.Lock() // a look running
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
for i := 0; i < 400; i++ {
|
||||
go func() { done <- nodeD.Take(key, "put", raw, rev) }()
|
||||
go func() { done <- shanks.Take(key, "put", raw, rev) }()
|
||||
}
|
||||
for i := 0; i < 400; i++ {
|
||||
select {
|
||||
@@ -957,12 +957,12 @@ func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
|
||||
if took := time.Since(start); took > time.Second {
|
||||
t.Fatalf("400 values took %s to hand over", took)
|
||||
}
|
||||
nodeD.mu.Unlock()
|
||||
shanks.mu.Unlock()
|
||||
close(slow.gate)
|
||||
if err := nodeD.Drain(); err != nil {
|
||||
if err := shanks.Drain(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatalf("what the watch noted was not applied: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func proposalTools(p Paths) []stdio.Tool {
|
||||
Input: map[string]any{
|
||||
"scope": str("mesh (every node) or node (the nodes given, or this one)"),
|
||||
"nodes": str("for scope node: a comma-separated list of nodes; absent is this node"),
|
||||
"name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-node-b"),
|
||||
"name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-g14"),
|
||||
"text": str("the section's text, markdown, as every session will read it"),
|
||||
"remove": map[string]any{"type": "boolean", "description": "propose removing the approved section of this name and scope instead"},
|
||||
},
|
||||
@@ -173,7 +173,7 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
|
||||
}, "required": []string{"label", "means"}}},
|
||||
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
|
||||
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@node-b"),
|
||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
||||
"expires_minutes": map[string]any{"type": "integer",
|
||||
"description": "minutes until it expires, 5 to 1430; 60 when absent"},
|
||||
},
|
||||
|
||||
@@ -55,7 +55,7 @@ type router struct {
|
||||
|
||||
func newRouter() *router {
|
||||
return &router{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{},
|
||||
kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "node-c",
|
||||
kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "novox",
|
||||
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ const handover = "Hand-over from the laptop.\n\nThe controller's merge of the as
|
||||
|
||||
func proposeHandover(t *testing.T, p Paths, store *memProposals, r *router) string {
|
||||
t.Helper()
|
||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Text: handover}, store, r, t0)
|
||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover}, store, r, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func renderedInstructions(t *testing.T, p Paths) string {
|
||||
}
|
||||
|
||||
func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
id := proposeHandover(t, p, store, r)
|
||||
|
||||
@@ -153,10 +153,10 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
||||
asks.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
|
||||
}
|
||||
if a.Headline != "Add Claude instructions 'handoff-from-node-b'?" {
|
||||
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
||||
t.Errorf("headline: %q", a.Headline)
|
||||
}
|
||||
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on node-b at ") {
|
||||
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") {
|
||||
t.Errorf("a short text is not shown whole: %q", a.Details)
|
||||
}
|
||||
if len(a.Options) != 2 {
|
||||
@@ -180,26 +180,26 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
||||
}
|
||||
|
||||
func TestApprovedTextReachesEveryNodeOnlyOnAWarrantEachNodeProves(t *testing.T) {
|
||||
nodeB, _ := node(t, "node-b")
|
||||
nodeD, _ := node(t, "node-d")
|
||||
g14, _ := node(t, "g14")
|
||||
shanks, _ := node(t, "shanks")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
id := proposeHandover(t, nodeB, store, r)
|
||||
id := proposeHandover(t, g14, store, r)
|
||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||
rec := &recorded{}
|
||||
changed, said, err := Reconcile(nodeD, store, r, rec.record, t0.Add(time.Hour+time.Minute))
|
||||
changed, said, err := Reconcile(shanks, store, r, rec.record, t0.Add(time.Hour+time.Minute))
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("node-d did not take the approved text: %v %v", err, said)
|
||||
t.Fatalf("shanks did not take the approved text: %v %v", err, said)
|
||||
}
|
||||
got := renderedInstructions(t, nodeD)
|
||||
if !strings.Contains(got, "## Instructions the operator approved") || !strings.Contains(got, "### handoff-from-node-b") ||
|
||||
got := renderedInstructions(t, shanks)
|
||||
if !strings.Contains(got, "## Instructions the operator approved") || !strings.Contains(got, "### handoff-from-g14") ||
|
||||
!strings.Contains(got, "Do not merge it before the gate is green.") {
|
||||
t.Fatalf("a new session on node-d does not read the text:\n%s", got)
|
||||
t.Fatalf("a new session on shanks does not read the text:\n%s", got)
|
||||
}
|
||||
if len(rec.asks) != 1 || rec.asks[0] != id {
|
||||
t.Errorf("the warrant was not handed to the hand-act log: %v", rec.asks)
|
||||
}
|
||||
// Heard again, or replayed: decided once, recorded once.
|
||||
if changed, _, _ := Reconcile(nodeD, store, r, rec.record, t0.Add(2*time.Hour)); changed || len(rec.asks) != 1 {
|
||||
if changed, _, _ := Reconcile(shanks, store, r, rec.record, t0.Add(2*time.Hour)); changed || len(rec.asks) != 1 {
|
||||
t.Errorf("a warrant heard twice acted twice (changed %v, recorded %v)", changed, rec.asks)
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,7 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p, _ := node(t, "node-d")
|
||||
p, _ := node(t, "shanks")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
id := proposeHandover(t, p, store, r)
|
||||
c.tamper(r, store, id)
|
||||
@@ -278,7 +278,7 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.T) {
|
||||
p, _ := node(t, "node-d")
|
||||
p, _ := node(t, "shanks")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
id := proposeHandover(t, p, store, r)
|
||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||
@@ -289,31 +289,31 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
|
||||
if _, _, err := Reconcile(p, store, r, nil, t0.Add(2*time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-node-b") {
|
||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
||||
t.Fatal("deleting the bus record removed an approved section")
|
||||
}
|
||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Remove: true}, store, r, t0.Add(3*time.Hour))
|
||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Remove: true}, store, r, t0.Add(3*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rid := out["proposed"].(string)
|
||||
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-node-b' from its next start." {
|
||||
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
|
||||
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
||||
}
|
||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-node-b") {
|
||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
||||
t.Fatal("proposing a removal removed it")
|
||||
}
|
||||
r.close(r.chose(rid, OptionApprove, t0.Add(4*time.Hour)))
|
||||
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(4*time.Hour)); !changed {
|
||||
t.Fatal("the approved removal changed nothing")
|
||||
}
|
||||
if strings.Contains(renderedInstructions(t, p), "handoff-from-node-b") {
|
||||
if strings.Contains(renderedInstructions(t, p), "handoff-from-g14") {
|
||||
t.Fatal("the approved removal left the section")
|
||||
}
|
||||
// The first approval, replayed into the bus again, brings nothing back: it was decided here.
|
||||
r2 := r.records[id]
|
||||
_ = store.Put(id, Proposal{ID: id, Action: ActionAdd, Scope: ScopeMesh, Name: "handoff-from-node-b", Text: handover,
|
||||
Digest: textDigest(handover), From: "node-b", At: t0})
|
||||
_ = store.Put(id, Proposal{ID: id, Action: ActionAdd, Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
|
||||
Digest: textDigest(handover), From: "g14", At: t0})
|
||||
r.records[id] = r2
|
||||
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(5*time.Hour)); changed {
|
||||
t.Fatal("a replayed approval brought a removed section back")
|
||||
@@ -321,22 +321,22 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
|
||||
}
|
||||
|
||||
func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
|
||||
nodeA, _ := node(t, "node-a")
|
||||
nodeD, _ := node(t, "node-d")
|
||||
ace, _ := node(t, "ace")
|
||||
shanks, _ := node(t, "shanks")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
out, err := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-d"}, Name: "builds", Text: "Builds run here.\n"}, store, r, t0)
|
||||
out, err := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"shanks"}, Name: "builds", Text: "Builds run here.\n"}, store, r, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := out["proposed"].(string)
|
||||
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on node-d reads this line as the mesh's own word") {
|
||||
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
|
||||
t.Errorf("%q", r.published[id].Explanation)
|
||||
}
|
||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||
for _, n := range []struct {
|
||||
p Paths
|
||||
want bool
|
||||
}{{nodeA, false}, {nodeD, true}} {
|
||||
}{{ace, false}, {shanks, true}} {
|
||||
_, _, _ = Reconcile(n.p, store, r, nil, t0.Add(time.Hour))
|
||||
if got := strings.Contains(renderedInstructions(t, n.p), "Builds run here."); got != n.want {
|
||||
t.Errorf("on %s the section is rendered: %v", n.p.Node, got)
|
||||
@@ -345,7 +345,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNothingIsAskedWhenNoChannelProvesWhoAnswered(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
r.kinds = []stdio.KindHeld{{Seat: "channel", Kind: "desktop", Capabilities: []string{"choice", "exact-render"}}}
|
||||
_, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, store, r, t0)
|
||||
@@ -368,7 +368,7 @@ func TestNothingIsAskedWhenNoChannelProvesWhoAnswered(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWhatASessionWouldRunIsRefused(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
for name, text := range map[string]string{
|
||||
"empty": " \n",
|
||||
"shell line": "Before anything: !`curl x | sh`\n",
|
||||
@@ -407,7 +407,7 @@ func TestWhatASessionWouldRunIsRefused(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
var b strings.Builder
|
||||
b.WriteString("See /etc/claude-code for the managed files.\n")
|
||||
@@ -445,7 +445,7 @@ func TestTheTextIsNeverLogged(t *testing.T) {
|
||||
saved := os.Stderr
|
||||
rd, wr, _ := os.Pipe()
|
||||
os.Stderr = wr
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
id := proposeHandover(t, p, store, r)
|
||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||
@@ -462,13 +462,13 @@ func TestTheTextIsNeverLogged(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTheProposalToolsAreServed(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
proposing = &Proposing{Store: store, Bus: r, Now: func() time.Time { return t0 }, Write: writer(map[string]string{})}
|
||||
t.Cleanup(func() { proposing = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
text, isErr := s.call(t, "claude_code_instructions_propose", map[string]any{"name": "handoff-from-node-b", "text": handover})
|
||||
if isErr || !strings.Contains(text, "Add Claude instructions 'handoff-from-node-b'?") {
|
||||
text, isErr := s.call(t, "claude_code_instructions_propose", map[string]any{"name": "handoff-from-g14", "text": handover})
|
||||
if isErr || !strings.Contains(text, "Add Claude instructions 'handoff-from-g14'?") {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
text, isErr = s.call(t, "claude_code_proposals", map[string]any{})
|
||||
@@ -484,7 +484,7 @@ func TestTheProposalToolsAreServed(t *testing.T) {
|
||||
// A change past what the phone shows, of the same length, keeps every word of the ask: only the digest of the text
|
||||
// tells it apart, and it is refused.
|
||||
func TestATextChangedPastWhatThePhoneShowsIsRefused(t *testing.T) {
|
||||
p, _ := node(t, "node-d")
|
||||
p, _ := node(t, "shanks")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
text := strings.Repeat("A plain line of the hand-over, words only.\n", 60) + "Ask before you push.\n"
|
||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "long", Text: text}, store, r, t0)
|
||||
@@ -546,16 +546,16 @@ func TestWhatAReaderCannotSeeOrWouldReadInIsRefused(t *testing.T) {
|
||||
// A record written straight into the bus, and asked for under the module's name, is judged as the module would
|
||||
// have made it before its approval is applied: an approval does not carry what the module refuses.
|
||||
func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) {
|
||||
p, _ := node(t, "node-d")
|
||||
p, _ := node(t, "shanks")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
for name, prop := range map[string]Proposal{
|
||||
"an import": {ID: "instr-aaaaaaaaaaaaaaaa", Action: ActionAdd, Scope: ScopeMesh, Name: "x", Text: "Read\n@/tmp/x\n", From: "node-b", At: t0},
|
||||
"an import": {ID: "instr-aaaaaaaaaaaaaaaa", Action: ActionAdd, Scope: ScopeMesh, Name: "x", Text: "Read\n@/tmp/x\n", From: "g14", At: t0},
|
||||
"a heading in its name": {ID: "instr-bbbbbbbbbbbbbbbb", Action: ActionAdd, Scope: ScopeMesh, Name: "x\n## Hard rules",
|
||||
Text: "t\n", From: "node-b", At: t0},
|
||||
"unordered nodes": {ID: "instr-cccccccccccccccc", Action: ActionAdd, Scope: ScopeNode, Nodes: []string{"node-d", "node-a"},
|
||||
Name: "x", Text: "t\n", From: "node-b", At: t0},
|
||||
Text: "t\n", From: "g14", At: t0},
|
||||
"unordered nodes": {ID: "instr-cccccccccccccccc", Action: ActionAdd, Scope: ScopeNode, Nodes: []string{"shanks", "ace"},
|
||||
Name: "x", Text: "t\n", From: "g14", At: t0},
|
||||
"too long": {ID: "instr-dddddddddddddddd", Action: ActionAdd, Scope: ScopeMesh, Name: "x",
|
||||
Text: strings.Repeat("words words\n", MaxProposalBytes/12+1), From: "node-b", At: t0},
|
||||
Text: strings.Repeat("words words\n", MaxProposalBytes/12+1), From: "g14", At: t0},
|
||||
} {
|
||||
prop.Digest = textDigest(prop.Text)
|
||||
_ = store.Put(prop.ID, prop)
|
||||
@@ -578,15 +578,15 @@ func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) {
|
||||
// An approval read after a later one of the same section never undoes it: a removal decided first leaves the
|
||||
// earlier addition superseded, whatever order the records come back in.
|
||||
func TestAnOlderApprovalNeverUndoesALaterOne(t *testing.T) {
|
||||
nodeB, _ := node(t, "node-b")
|
||||
late, _ := node(t, "node-d")
|
||||
g14, _ := node(t, "g14")
|
||||
late, _ := node(t, "shanks")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
add := proposeHandover(t, nodeB, store, r)
|
||||
add := proposeHandover(t, g14, store, r)
|
||||
r.close(r.chose(add, OptionApprove, t0.Add(time.Hour)))
|
||||
if _, _, err := Reconcile(nodeB, store, r, nil, t0.Add(time.Hour)); err != nil {
|
||||
if _, _, err := Reconcile(g14, store, r, nil, t0.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Propose(nodeB, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Remove: true}, store, r, t0.Add(2*time.Hour))
|
||||
out, err := Propose(g14, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Remove: true}, store, r, t0.Add(2*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -602,7 +602,7 @@ func TestAnOlderApprovalNeverUndoesALaterOne(t *testing.T) {
|
||||
if changed, _, _ := Reconcile(late, store, r, nil, t0.Add(5*time.Hour)); changed {
|
||||
t.Fatal("the earlier addition, read after the removal, was applied")
|
||||
}
|
||||
if strings.Contains(renderedInstructions(t, late), "handoff-from-node-b") {
|
||||
if strings.Contains(renderedInstructions(t, late), "handoff-from-g14") {
|
||||
t.Fatal("a removed section came back")
|
||||
}
|
||||
if got := outcomeHere(t, late, add); !strings.Contains(got, "superseded") {
|
||||
@@ -611,7 +611,7 @@ func TestAnOlderApprovalNeverUndoesALaterOne(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAnUnreadableRecordIsNeverTakenForAnEmptyOne(t *testing.T) {
|
||||
p, _ := node(t, "node-d")
|
||||
p, _ := node(t, "shanks")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
id := proposeHandover(t, p, store, r)
|
||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||
@@ -630,27 +630,27 @@ func TestAnUnreadableRecordIsNeverTakenForAnEmptyOne(t *testing.T) {
|
||||
// A section for some nodes is its own: a proposal of the same name for other nodes adds another, and replaces
|
||||
// or removes nothing of the first.
|
||||
func TestANodeSectionIsNotReplacedFromOtherNodes(t *testing.T) {
|
||||
nodeA, _ := node(t, "node-a")
|
||||
ace, _ := node(t, "ace")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
out, _ := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-a"}, Name: "builds", Text: "On node-a.\n"}, store, r, t0)
|
||||
out, _ := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"ace"}, Name: "builds", Text: "On ace.\n"}, store, r, t0)
|
||||
r.close(r.chose(out["proposed"].(string), OptionApprove, t0.Add(time.Hour)))
|
||||
_, _, _ = Reconcile(nodeA, store, r, nil, t0.Add(time.Hour))
|
||||
out, err := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-d"}, Name: "builds", Text: "On node-d.\n"}, store, r, t0.Add(2*time.Hour))
|
||||
_, _, _ = Reconcile(ace, store, r, nil, t0.Add(time.Hour))
|
||||
out, err := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"shanks"}, Name: "builds", Text: "On shanks.\n"}, store, r, t0.Add(2*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["action"] != ActionAdd {
|
||||
t.Errorf("a section for other nodes was proposed as %v", out["action"])
|
||||
}
|
||||
if _, err := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-d"}, Name: "builds", Remove: true}, store, r, t0.Add(2*time.Hour)); err == nil {
|
||||
t.Error("a removal for other nodes was taken for the section on node-a")
|
||||
if _, err := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"shanks"}, Name: "builds", Remove: true}, store, r, t0.Add(2*time.Hour)); err == nil {
|
||||
t.Error("a removal for other nodes was taken for the section on ace")
|
||||
}
|
||||
}
|
||||
|
||||
// A module that only asks is issued no channel kinds (they come with a kinded bench a module uses or watches): an
|
||||
// empty list is no refusal, and the router, which judges, is asked (the live failure of 2026-10-10).
|
||||
func TestAnAskerIssuedNoKindsLeavesTheJudgingToTheRouter(t *testing.T) {
|
||||
p, _ := node(t, "node-c")
|
||||
p, _ := node(t, "novox")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
r.kinds = nil
|
||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, store, r, t0)
|
||||
@@ -664,7 +664,7 @@ func TestAnAskerIssuedNoKindsLeavesTheJudgingToTheRouter(t *testing.T) {
|
||||
|
||||
// The router's refusal reaches the proposer at once, and discards the proposal on every node that hears it.
|
||||
func TestTheRoutersRefusalIsSaidAndDiscards(t *testing.T) {
|
||||
p, _ := node(t, "node-c")
|
||||
p, _ := node(t, "novox")
|
||||
store := &memProposals{m: map[string]json.RawMessage{}}
|
||||
r := newRouter()
|
||||
r.kinds = nil
|
||||
@@ -687,7 +687,7 @@ func TestTheRoutersRefusalIsSaidAndDiscards(t *testing.T) {
|
||||
|
||||
// A proposal whose ask ended and that the router keeps no record of is discarded, never left waiting.
|
||||
func TestAnAskTheRouterKeepsNoRecordOfIsDiscardedAfterItsTime(t *testing.T) {
|
||||
p, _ := node(t, "node-c")
|
||||
p, _ := node(t, "novox")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
id := proposeHandover(t, p, store, r)
|
||||
delete(r.records, id)
|
||||
@@ -726,7 +726,7 @@ func (r *refusingRouter) Publish(subject string, body any, id string) error {
|
||||
|
||||
// A refusal heard, and then the same ask taken after all and approved: the approval is taken, not lost in silence.
|
||||
func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
|
||||
p, _ := node(t, "node-c")
|
||||
p, _ := node(t, "novox")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
id := proposeHandover(t, p, store, r)
|
||||
if _, err := HeardEnd(p, store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, Words: "no channel", At: t0}, t0); err != nil {
|
||||
@@ -747,9 +747,9 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
|
||||
// A proposer's summary is the explanation, held to a glance; a proposal made before issue 639 is composed as it was
|
||||
// asked, so the warrant for it still holds after the change.
|
||||
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Text: handover,
|
||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
|
||||
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -351,7 +351,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
||||
by = who
|
||||
}
|
||||
if by == "" {
|
||||
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@node-b")
|
||||
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@g14")
|
||||
}
|
||||
life := in.Expires
|
||||
if life == 0 {
|
||||
|
||||
@@ -26,7 +26,7 @@ func mergeTonight() QuestionInput {
|
||||
},
|
||||
Why: "it is reviewed and the checks are green",
|
||||
OnExpiry: "I leave the pull request open",
|
||||
By: "fix-589@node-b",
|
||||
By: "fix-589@g14",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func askMergeTonight(t *testing.T, p Paths, store *memQuestions, r *router, at t
|
||||
}
|
||||
|
||||
func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
out, err := AskQuestion(p, mergeTonight(), store, r, t0)
|
||||
if err != nil {
|
||||
@@ -72,7 +72,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
|
||||
}
|
||||
for _, want := range []string{"the agent session fix-589 on node-b", "performs nothing by itself"} {
|
||||
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
|
||||
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
|
||||
t.Errorf("the details do not say %q:\n%s", want, a.Details)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
out, err := ReadAnswer(store, r, key, t0.Add(time.Minute))
|
||||
@@ -105,7 +105,7 @@ func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
w := r.chose(key, "o1", t0.Add(time.Minute))
|
||||
@@ -140,7 +140,7 @@ func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
for name, change := range map[string]func(*QuestionInput){
|
||||
"one option": func(in *QuestionInput) { in.Options = in.Options[:1] },
|
||||
"five options": func(in *QuestionInput) { in.Options = append(in.Options, in.Options[1], in.Options[1], in.Options[1]) },
|
||||
@@ -154,7 +154,7 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
||||
"an option unsaid": func(in *QuestionInput) { in.Options[1].Means = "" },
|
||||
"too short": func(in *QuestionInput) { in.Expires = time.Minute },
|
||||
"too long": func(in *QuestionInput) { in.Expires = 2 * 24 * time.Hour },
|
||||
"another node": func(in *QuestionInput) { in.By = "fix-589@node-d" },
|
||||
"another node": func(in *QuestionInput) { in.By = "fix-589@shanks" },
|
||||
"an invisible char": func(in *QuestionInput) { in.Context += "\u200b" },
|
||||
"invisible label": func(in *QuestionInput) { in.Options[0].Label = "Merge\u200bnow" },
|
||||
"invisible means": func(in *QuestionInput) { in.Options[1].Means += "\u202e" },
|
||||
@@ -182,7 +182,7 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
first := askMergeTonight(t, p, store, r, t0)
|
||||
askMergeTonight(t, p, store, r, t0)
|
||||
@@ -219,7 +219,7 @@ func (r *questionRefusingRouter) Publish(subject string, body any, id string) er
|
||||
}
|
||||
|
||||
func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemQuestions()
|
||||
refusing := &questionRefusingRouter{router: newRouter(), store: store}
|
||||
within, every := RouterAnswersWithin, RouterAnswersEvery
|
||||
@@ -244,7 +244,7 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
|
||||
@@ -255,7 +255,7 @@ func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTheQuestionToolsAreServed(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
r := newRouter()
|
||||
questions := newMemQuestions()
|
||||
proposing = &Proposing{Store: &memProposals{m: map[string]json.RawMessage{}}, Bus: r, Now: func() time.Time { return t0 },
|
||||
@@ -268,7 +268,7 @@ func TestTheQuestionToolsAreServed(t *testing.T) {
|
||||
map[string]any{"label": "Merge now", "means": "I merge it", "recommended": true},
|
||||
map[string]any{"label": "Wait", "means": "I leave it open"},
|
||||
},
|
||||
"why": "it is reviewed", "on_expiry": "I leave it open", "by": "fix-589@node-b", "expires_minutes": 30,
|
||||
"why": "it is reviewed", "on_expiry": "I leave it open", "by": "fix-589@g14", "expires_minutes": 30,
|
||||
})
|
||||
if isErr || !strings.Contains(text, `"key"`) {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
@@ -292,7 +292,7 @@ func TestAReadWaitsNoLongerThanItsBound(t *testing.T) {
|
||||
if AnswerWaitsAtMost != 60*time.Second {
|
||||
t.Fatalf("a read may wait %s; ADR 0332 says 60 s", AnswerWaitsAtMost)
|
||||
}
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
most, every := AnswerWaitsAtMost, AnswerLooksEvery
|
||||
@@ -369,7 +369,7 @@ func (s *memQuestions) Put(key string, value any) error {
|
||||
|
||||
// The edges of ADR 0332 §2 that are allowed: a 60-character headline and 1430 minutes.
|
||||
func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
in := mergeTonight()
|
||||
in.Headline = strings.Repeat("x", 59) + "?"
|
||||
in.Expires = 1430 * time.Minute
|
||||
@@ -380,7 +380,7 @@ func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
|
||||
|
||||
// A warrant naming another asker or another ask, or an option the question did not offer, is no answer to it.
|
||||
func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
for name, change := range map[string]func(*asks.Warrant){
|
||||
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
|
||||
"another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" },
|
||||
@@ -400,7 +400,7 @@ func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
|
||||
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
|
||||
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
|
||||
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
|
||||
nodes := []string{"node-b", "node-d", "node-a"}
|
||||
nodes := []string{"g14", "shanks", "ace"}
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(nodes))
|
||||
for i, name := range nodes {
|
||||
@@ -430,7 +430,7 @@ func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
|
||||
|
||||
// A slot that cannot be read refuses the question: the cap fails closed.
|
||||
func TestTheCapFailsClosed(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
store.failGet = "slot-"
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
|
||||
@@ -444,7 +444,7 @@ func TestTheCapFailsClosed(t *testing.T) {
|
||||
// A question whose refusal was not heard here, and of which the router holds no record, keeps its slot and reads as
|
||||
// waiting until it expires — the router may still take it from its queue — and only then frees its slot and ends.
|
||||
func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
first := askMergeTonight(t, p, store, r, t0)
|
||||
askMergeTonight(t, p, store, r, t0)
|
||||
@@ -470,13 +470,13 @@ func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T
|
||||
// A slot is taken only at the revision read: when another node takes both slots between this node's read and its
|
||||
// write, this node's writes are refused and the question is refused — a plain write would take a slot it lost.
|
||||
func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
// Two questions that ended: their slots are free to take again.
|
||||
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
|
||||
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(time.Minute)})
|
||||
}
|
||||
nodeD, _ := node(t, "node-d")
|
||||
shanks, _ := node(t, "shanks")
|
||||
other := newRouter()
|
||||
store.afterGet = func(key string) {
|
||||
if !strings.HasPrefix(key, "slot-") {
|
||||
@@ -484,7 +484,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
|
||||
}
|
||||
// Another node asks between this node's read of the slot and its write: its question waits.
|
||||
q := Question{ID: newQuestionID(), Headline: "Another", Context: "c", Why: "w", OnExpiry: "e", By: "other",
|
||||
From: nodeD.Node, At: t0, Expires: t0.Add(time.Hour),
|
||||
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
|
||||
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
|
||||
_ = store.Put(q.ID, q)
|
||||
_ = other.Publish(asks.AskSubject(Module), q.Ask(), q.ID)
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) {
|
||||
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
|
||||
idx := NodeIndex{Node: "node-b", At: lastCall, Names: []NodeIndexEntry{
|
||||
idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{
|
||||
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
|
||||
}}
|
||||
raw, err := indented(idx)
|
||||
@@ -135,7 +135,7 @@ func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
|
||||
if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") {
|
||||
t.Fatalf("the name and its count are not listed:\n%s", msg)
|
||||
}
|
||||
want := `claude_code_wait {"op":"adopt","by":"fix-1629@node-b","session":"sess-brand-new"}`
|
||||
want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}`
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want)
|
||||
}
|
||||
|
||||
@@ -77,13 +77,13 @@ func addApproveWait(t *testing.T, p Paths, store WaitStore, name, session string
|
||||
|
||||
// 1629/AC1: add answers an id; list answers the open waits with their age and whether each is past its bound.
|
||||
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0)
|
||||
if !strings.HasPrefix(id, WaitPrefix) {
|
||||
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
|
||||
}
|
||||
out, err := ListWaits(p, "sess-1", "fix-1629@node-b", store, t0.Add(time.Minute))
|
||||
out, err := ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
|
||||
if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 {
|
||||
t.Fatalf("%+v", open)
|
||||
}
|
||||
out, err = ListWaits(p, "sess-1", "fix-1629@node-b", store, t0.Add(11*time.Minute))
|
||||
out, err = ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(11*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -104,9 +104,9 @@ func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
|
||||
// 1629/AC2: watched, done and said change the wait; said is refused for a wait not past its bound; done removes
|
||||
// it from the open list.
|
||||
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@node-b"
|
||||
by := "fix-1629@g14"
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0)
|
||||
|
||||
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") {
|
||||
@@ -143,9 +143,9 @@ func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
|
||||
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
|
||||
// and the write time.
|
||||
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@node-b"
|
||||
by := "fix-1629@g14"
|
||||
id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0)
|
||||
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil {
|
||||
@@ -179,9 +179,9 @@ func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
|
||||
// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same name
|
||||
// after a /clear that keeps calling with the same session id; a different name lists none of them.
|
||||
func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@node-b"
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-4", t0)
|
||||
|
||||
// A restart: a fresh Waiting over the same store reads the same waits.
|
||||
@@ -199,7 +199,7 @@ func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
|
||||
}
|
||||
|
||||
// A different name, same node: none of them.
|
||||
out, err = ListWaits(p, "sess-other", "fix-other@node-b", store, t0.Add(2*time.Minute))
|
||||
out, err = ListWaits(p, "sess-other", "fix-other@g14", store, t0.Add(2*time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("another name saw them: %v %v", out, err)
|
||||
}
|
||||
@@ -208,7 +208,7 @@ func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
|
||||
// 1629/AC5: add refuses an empty what, a bound under 1 or over 1440 minutes, a read address that is not an
|
||||
// address, and more than 50 open waits per name, naming the argument.
|
||||
func TestAddRefusesOutsideItsBounds(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
base := func() WaitInput {
|
||||
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
|
||||
}
|
||||
@@ -247,8 +247,8 @@ func TestAddRefusesOutsideItsBounds(t *testing.T) {
|
||||
// Review round 1 item (b): list, watched, done and said refuse a session id that is not a plain one (the
|
||||
// same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key.
|
||||
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
by := "fix-1629@node-b"
|
||||
p, _ := node(t, "g14")
|
||||
by := "fix-1629@g14"
|
||||
for _, bad := range []string{"", "a/b", "../x"} {
|
||||
store := newMemWaits()
|
||||
if _, err := ListWaits(p, bad, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
@@ -268,13 +268,13 @@ func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
|
||||
|
||||
// 1629/AC6: the tool is served through the module's own dispatch.
|
||||
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@node-b",
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
@@ -288,12 +288,12 @@ func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
t.Fatalf("no id answered: %s", text)
|
||||
}
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@node-b"})
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@g14"})
|
||||
if isErr || !strings.Contains(text, id) {
|
||||
t.Fatalf("list: %v %s", isErr, text)
|
||||
}
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@node-b",
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@g14",
|
||||
"id": id, "outcome": "approved"})
|
||||
if isErr {
|
||||
t.Fatalf("done: %s", text)
|
||||
@@ -305,10 +305,10 @@ func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
// ADR 0490 §1: a name nothing has bound yet is claimed by the first session that calls for it (add), without
|
||||
// a separate adopt first.
|
||||
func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0)
|
||||
out, err := ListWaits(p, "sess-a", "fix-1629@node-b", store, t0)
|
||||
out, err := ListWaits(p, "sess-a", "fix-1629@g14", store, t0)
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id {
|
||||
t.Fatalf("%v %v", out, err)
|
||||
}
|
||||
@@ -317,9 +317,9 @@ func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
|
||||
// ADR 0490 §1/§3: once a name is bound to a session, a different, non-adopted session calling for the same
|
||||
// name (any op) is refused, naming the session that holds it and when it last called.
|
||||
func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@node-b"
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
|
||||
if _, err := AddWait(p, WaitInput{Session: "sess-new", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
@@ -341,9 +341,9 @@ func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
|
||||
// ADR 0490 §2/§3: adopt right after a simulated /clear binds the name to the new session id at once; the
|
||||
// mirrored file of the old session id is emptied and the new one holds the waits, with no delay.
|
||||
func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@node-b"
|
||||
by := "fix-1629@g14"
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
|
||||
out, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute))
|
||||
@@ -376,9 +376,9 @@ func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
|
||||
// ADR 0490 §3: adopt of a name bound to another id always takes it over, however recently that id called —
|
||||
// no bound protects it.
|
||||
func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@node-b"
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0)
|
||||
// sess-fresh calls again right before being adopted away from.
|
||||
if _, err := ListWaits(p, "sess-fresh", by, store, t0.Add(10*time.Second)); err != nil {
|
||||
@@ -396,9 +396,9 @@ func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
|
||||
// ADR 0490 §3: the earlier id's next claude_code_wait call (any op) answers that its name now answers to
|
||||
// another session, naming it.
|
||||
func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@node-b"
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -413,11 +413,11 @@ func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
|
||||
// ADR 0490 §2: the node-wide index lists every name on this node with an open wait, for the SessionStart
|
||||
// hook to read directly.
|
||||
func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-x", t0)
|
||||
addApproveWait(t, p, store, "fix-1744", "sess-y", t0)
|
||||
if _, err := DoneWait(p, "sess-x", "fix-1629@node-b", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
|
||||
if _, err := DoneWait(p, "sess-x", "fix-1629@g14", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
|
||||
store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -426,7 +426,7 @@ func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx.Node != "node-b" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
|
||||
if idx.Node != "g14" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
|
||||
idx.Names[0].CurrentSession != "sess-y" {
|
||||
t.Fatalf("%+v", idx)
|
||||
}
|
||||
@@ -448,18 +448,18 @@ func wantsOnlyWait(t *testing.T, store WaitStore, p Paths, name, session string)
|
||||
|
||||
// claude_code_wait op=adopt, served through the module's own dispatch (1629/AC6, ADR 0490).
|
||||
func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@node-b",
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@node-b"})
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@g14"})
|
||||
if isErr || !strings.Contains(text, "sess-old") {
|
||||
t.Fatalf("adopt: %v %s", isErr, text)
|
||||
}
|
||||
@@ -470,7 +470,7 @@ func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
// Review round 2, item 2 (ADR 0490 §1): one session id with two different names bound to it holds both
|
||||
// names' waits in its one mirrored file, not only the name an op last acted on.
|
||||
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
||||
@@ -491,12 +491,12 @@ func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
|
||||
// Review round 2, item 2: adopting one of two names bound to the same session away keeps that session's
|
||||
// file holding the other name's waits — it is never emptied wholesale.
|
||||
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
||||
|
||||
if _, err := AdoptName(p, "fix-y@node-b", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
|
||||
if _, err := AdoptName(p, "fix-y@g14", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -513,9 +513,9 @@ func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
|
||||
// Review round 2, item b: adopt clears the refusal counter of both the earlier id and the new one, so a
|
||||
// fresh three-strikes count starts under the new binding.
|
||||
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@node-b"
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
waitsDir := WaitsDirPath(p)
|
||||
if err := writeRefusalCounters(waitsDir, "sess-old", map[string]int{"w-1": 2}); err != nil {
|
||||
@@ -538,9 +538,9 @@ func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
|
||||
// Review round 2, item c: list is read-only — it never binds an unbound name, and never refuses a
|
||||
// mismatched session; it answers the name's waits and who currently holds it.
|
||||
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
|
||||
p, _ := node(t, "node-b")
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@node-b"
|
||||
by := "fix-1629@g14"
|
||||
|
||||
// An unbound name: list answers no waits, and binds nothing (a later add from a different session still
|
||||
// succeeds, as the first caller for an unbound name).
|
||||
|
||||
@@ -347,7 +347,7 @@ func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
|
||||
func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("node-c", "rt-new", true, t0)})
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
||||
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||
if mm.state["laptop"].Licence != licence1 {
|
||||
@@ -477,16 +477,16 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "node-b.node-tools"})
|
||||
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
||||
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "node-b.node-tools" {
|
||||
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
||||
t.Fatalf("the node is told %+v", s)
|
||||
}
|
||||
said := strings.Join(lines, "\n")
|
||||
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
|
||||
!strings.Contains(said, "the switch verb; asked by node-b.node-tools") {
|
||||
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
||||
t.Fatalf("the journal said:\n%s", said)
|
||||
}
|
||||
var switched string
|
||||
@@ -495,18 +495,18 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
||||
switched = e
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"by":"node-b.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
||||
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
||||
if !strings.Contains(switched, want) {
|
||||
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
||||
}
|
||||
}
|
||||
last := mm.store.Audits[len(mm.store.Audits)-1]
|
||||
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"node-b.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
||||
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
||||
t.Fatalf("the audit kept %s", raw)
|
||||
}
|
||||
|
||||
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "node-c.node-tools"}), licence1, true)
|
||||
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"node-c.node-tools"`) {
|
||||
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
||||
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
|
||||
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Research 027 asked for "`cups` with the printer's driver". Measured on 2026-10-0
|
||||
| workstation | queue | device | prints through | driver package installed |
|
||||
|---|---|---|---|---|
|
||||
| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused |
|
||||
| desktop | `Brother_MFC_NodeC` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused |
|
||||
| desktop | `Brother_MFC_Novox` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused |
|
||||
| desktop | `Kanjuro` (Canon PIXMA MG4200) | `cnijnet:` | the vendor's PPD and filter | `cnijfilter-mg4200` 3.80 (AUR) |
|
||||
|
||||
**Both Brother queues already print without a vendor driver.** CUPS's own IPP Everywhere support,
|
||||
|
||||
@@ -36,13 +36,13 @@ func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fak
|
||||
case "lpstat -r":
|
||||
return ok("scheduler is running\n")
|
||||
case "lpstat -d":
|
||||
return ok("system default destination: Brother_MFC_NodeC\n")
|
||||
return ok("system default destination: Brother_MFC_Novox\n")
|
||||
case "lpstat -p":
|
||||
return ok("printer Brother_MFC_NodeC is idle. enabled since Mon Jun 29 21:34:30 2026\n" +
|
||||
return ok("printer Brother_MFC_Novox is idle. enabled since Mon Jun 29 21:34:30 2026\n" +
|
||||
"printer Kanjuro disabled since Sun Jun 9 11:16:03 2024 -\n\tPaused\n")
|
||||
case "lpstat -a":
|
||||
return ok("Brother_MFC_NodeC accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n")
|
||||
case "lpoptions -p Brother_MFC_NodeC":
|
||||
return ok("Brother_MFC_Novox accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n")
|
||||
case "lpoptions -p Brother_MFC_Novox":
|
||||
return ok(`copies=1 device-uri=ipp://192.0.2.171/ipp/port1 finishings=3 marker-levels=70,100,8,100 marker-low-levels=10,10,10,10 marker-names='Black\ Toner\ Cartridge,Cyan\ Toner\ Cartridge,Magenta\ Toner\ Cartridge,Yellow\ Toner\ Cartridge' marker-types=toner,toner,toner,toner printer-is-shared=false printer-make-and-model='Printer - IPP Everywhere' printer-state-reasons=none`)
|
||||
case "lpoptions -p Kanjuro":
|
||||
return ok(`device-uri=cnijnet:/18-0C-AC-B0-62-83 printer-is-shared=false printer-make-and-model='Canon MG4200 series Ver.3.80' printer-state-reasons=paused`)
|
||||
@@ -54,7 +54,7 @@ func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fak
|
||||
func TestPrintersReadsStateDeviceModelAndSupplies(t *testing.T) {
|
||||
theDesktop(t, nil)
|
||||
got, err := Printers()
|
||||
if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_NodeC" || got.Scheduler != "scheduler is running" {
|
||||
if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_Novox" || got.Scheduler != "scheduler is running" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
b, k := got.Printers[0], got.Printers[1]
|
||||
@@ -186,11 +186,11 @@ func TestDefaultReadsAndSetsThroughSudo(t *testing.T) {
|
||||
return Result{}, false
|
||||
})
|
||||
got, err := Default("")
|
||||
if err != nil || got["default"] != "Brother_MFC_NodeC" {
|
||||
if err != nil || got["default"] != "Brother_MFC_Novox" {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
got, err = Default("Kanjuro")
|
||||
if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_NodeC" {
|
||||
if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_Novox" {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if l := f.lines(); l[len(l)-1] != "sudo -n lpadmin -d Kanjuro" {
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
|
||||
d, shown, said, store := newDesk(t, "laptop")
|
||||
d.Paused("laptop", false)
|
||||
d.SayStanding()
|
||||
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on node-d",
|
||||
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
||||
take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
|
||||
@@ -162,7 +162,7 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
|
||||
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
|
||||
}
|
||||
// The router says the open one again under its handle: in place of its notification, with its answers.
|
||||
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on node-d",
|
||||
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
||||
last := (*shown)[len(*shown)-1]
|
||||
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
|
||||
@@ -329,7 +329,7 @@ func TestItRunsAsItsOwnAccount(t *testing.T) {
|
||||
// was not shown; a message under a new handle replaces nothing.
|
||||
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
||||
d, shown, _, store := newDesk(t, "laptop", "desktop")
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on node-d"})
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m2", Title: "another problem"})
|
||||
if len(*shown) != 4 {
|
||||
t.Fatalf("shown %d", len(*shown))
|
||||
@@ -342,7 +342,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
||||
first := store["m1"].On
|
||||
// The desk now names a third machine as well.
|
||||
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
|
||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on node-d", Urgent: true,
|
||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
again := (*shown)[4:]
|
||||
if len(again) != 3 {
|
||||
@@ -367,7 +367,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
||||
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
|
||||
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
||||
d, shown, said, store := newDesk(t, "laptop", "desktop")
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on node-d"})
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
first := store["m1"].On
|
||||
notify := d.Notify
|
||||
d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||
@@ -376,12 +376,12 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
||||
}
|
||||
return notify(machine, args)
|
||||
}
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on node-d"})
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
|
||||
if got := store["m1"].On["desktop"]; got != first["desktop"] {
|
||||
t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On)
|
||||
}
|
||||
d.Notify = func(string, map[string]any) (json.RawMessage, error) { return nil, errors.New("away") }
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on node-d"})
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
|
||||
if len(said[asks.IntakeSubject(asks.FailedWhat, Kind)]) != 1 {
|
||||
t.Fatalf("shown nowhere, and not said as failed")
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ func TestTheManifestNamesExactlyTheToolsTheBundleServesAndNoMachine(t *testing.T
|
||||
if len(m.Resources) != 0 {
|
||||
t.Errorf("the module only reads, and declares %d resources", len(m.Resources))
|
||||
}
|
||||
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
|
||||
for _, banned := range []string{"jochen", "/home/"} {
|
||||
for _, banned := range []string{"g14", "novox", "ace", "shanks", "jochen", "/home/"} {
|
||||
if strings.Contains(string(raw), banned) {
|
||||
t.Errorf("the manifest says %q", banned)
|
||||
}
|
||||
|
||||
@@ -134,8 +134,8 @@ func aResolverMachine(t *testing.T, port string, now func() time.Time) *Machine
|
||||
}
|
||||
}
|
||||
write(ConfigFile, "bind-interfaces\nlisten-address=127.0.0.2\nlisten-address=127.0.0.1\nno-resolv\n")
|
||||
write(NodesFile, "local=/internal/\naddress=/node-c.internal/127.0.0.2\nhost-record=node-c.internal,127.0.0.2\n"+
|
||||
"address=/node-a.internal/10.77.0.2\nhost-record=node-a.internal,10.77.0.2\n")
|
||||
write(NodesFile, "local=/internal/\naddress=/novox.internal/127.0.0.2\nhost-record=novox.internal,127.0.0.2\n"+
|
||||
"address=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n")
|
||||
if err := os.MkdirAll(filepath.Join(root, filepath.Dir(StateFile)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -147,13 +147,13 @@ func TestTheConfigurationAndTheRosterSayWhereAndWhatToAsk(t *testing.T) {
|
||||
if strings.Join(listen, " ") != "10.77.0.1 127.0.0.1" {
|
||||
t.Fatalf("listen addresses %v", listen)
|
||||
}
|
||||
names := AnsweredNames("local=/internal/\naddress=/node-a.internal/10.77.0.2\nhost-record=node-a.internal,10.77.0.2\n" +
|
||||
"host-record=node-c.internal,10.77.0.1\n")
|
||||
names := AnsweredNames("local=/internal/\naddress=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n" +
|
||||
"host-record=novox.internal,10.77.0.1\n")
|
||||
q, ok := Question(listen, names)
|
||||
if !ok || q.Name != "node-c.internal" || q.Address != "10.77.0.1" {
|
||||
if !ok || q.Name != "novox.internal" || q.Address != "10.77.0.1" {
|
||||
t.Fatalf("asks %+v", q)
|
||||
}
|
||||
if q, _ := Question([]string{"127.0.0.1"}, names); q.Name != "node-a.internal" {
|
||||
if q, _ := Question([]string{"127.0.0.1"}, names); q.Name != "ace.internal" {
|
||||
t.Fatalf("with no own name in the roster, asks %+v", q)
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func TestALookFindsAResolverThatStoppedAnsweringOnOneAddress(t *testing.T) {
|
||||
m := aResolverMachine(t, port, time.Now)
|
||||
private := startStandIn(t, "127.0.0.2", port, "127.0.0.2")
|
||||
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
|
||||
if l := m.Look(t.Context()); !l.Healthy || len(l.Answers) != 4 || l.Asked.Name != "node-c.internal" {
|
||||
if l := m.Look(t.Context()); !l.Healthy || len(l.Answers) != 4 || l.Asked.Name != "novox.internal" {
|
||||
t.Fatalf("a resolver answering everywhere: %+v", l)
|
||||
}
|
||||
private.stop()
|
||||
@@ -173,14 +173,14 @@ func TestALookFindsAResolverThatStoppedAnsweringOnOneAddress(t *testing.T) {
|
||||
if l.Healthy || len(l.Wrong) != 2 {
|
||||
t.Fatalf("a resolver that stopped answering on its private address: %+v", l)
|
||||
}
|
||||
if why := l.Why(); !strings.Contains(why, "127.0.0.2:"+port+" over udp did not answer node-c.internal: refused") ||
|
||||
if why := l.Why(); !strings.Contains(why, "127.0.0.2:"+port+" over udp did not answer novox.internal: refused") ||
|
||||
!strings.Contains(why, "over tcp") || strings.Contains(why, "127.0.0.1:") {
|
||||
t.Fatalf("said as %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongAnswerIsNotAnAnswer(t *testing.T) {
|
||||
q := Named{Name: "node-c.internal", Address: "10.77.0.1"}
|
||||
q := Named{Name: "novox.internal", Address: "10.77.0.1"}
|
||||
for _, c := range []struct {
|
||||
a Answer
|
||||
want string
|
||||
@@ -198,7 +198,7 @@ func TestAWrongAnswerIsNotAnAnswer(t *testing.T) {
|
||||
|
||||
func failing(at time.Time) Look {
|
||||
return Look{At: at, Answers: []Answer{{Server: "10.77.0.1:53", Over: "udp", Failed: "refused: nothing listens there"}},
|
||||
Wrong: []string{"10.77.0.1:53 over udp did not answer node-c.internal: refused: nothing listens there"}}
|
||||
Wrong: []string{"10.77.0.1:53 over udp did not answer novox.internal: refused: nothing listens there"}}
|
||||
}
|
||||
|
||||
func healthy(at time.Time) Look { return Look{At: at, Healthy: true} }
|
||||
@@ -323,7 +323,7 @@ func TestTheWatchRestartsAResolverThatStoppedAnsweringAndHealthSaysWhetherItHelp
|
||||
look()
|
||||
}
|
||||
h := m.Health(t.Context())
|
||||
if h.Healthy || !strings.Contains(h.Why, "did not answer node-c.internal: refused") ||
|
||||
if h.Healthy || !strings.Contains(h.Why, "did not answer novox.internal: refused") ||
|
||||
!strings.Contains(h.Why, "which did not help") {
|
||||
t.Fatalf("a restart that did not help: %q", h.Why)
|
||||
}
|
||||
@@ -482,10 +482,10 @@ func TestResolveAsksOnlyAListenAddress(t *testing.T) {
|
||||
m := aResolverMachine(t, port, time.Now)
|
||||
startStandIn(t, "127.0.0.2", port, "127.0.0.2")
|
||||
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
|
||||
if _, err := m.Resolve(t.Context(), "node-c.internal", "8.8.8.8", ""); err == nil || !strings.Contains(err.Error(), "not one the resolver listens on") {
|
||||
if _, err := m.Resolve(t.Context(), "novox.internal", "8.8.8.8", ""); err == nil || !strings.Contains(err.Error(), "not one the resolver listens on") {
|
||||
t.Fatalf("another address was asked: %v", err)
|
||||
}
|
||||
got, err := m.Resolve(t.Context(), "node-c.internal", "127.0.0.2", "tcp")
|
||||
got, err := m.Resolve(t.Context(), "novox.internal", "127.0.0.2", "tcp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -493,10 +493,10 @@ func TestResolveAsksOnlyAListenAddress(t *testing.T) {
|
||||
if len(answers) != 1 || answers[0].Over != "tcp" || answers[0].Code != "NOERROR" || answers[0].Addresses[0] != "127.0.0.2" {
|
||||
t.Fatalf("%+v", answers)
|
||||
}
|
||||
if got, _ := m.Resolve(t.Context(), "node-c.internal", "", ""); len(got.(map[string]any)["answers"].([]Answer)) != 4 {
|
||||
if got, _ := m.Resolve(t.Context(), "novox.internal", "", ""); len(got.(map[string]any)["answers"].([]Answer)) != 4 {
|
||||
t.Fatalf("every listen address over both: %+v", got)
|
||||
}
|
||||
if _, err := m.Resolve(t.Context(), "node-c.internal", "", "quic"); err == nil {
|
||||
if _, err := m.Resolve(t.Context(), "novox.internal", "", "quic"); err == nil {
|
||||
t.Fatal("over quic was accepted")
|
||||
}
|
||||
if _, err := m.Resolve(t.Context(), "", "", ""); err == nil {
|
||||
@@ -540,12 +540,12 @@ func TestTheAnnouncerSaysWhatChangedAndRemembersOnlyWhatWasSaid(t *testing.T) {
|
||||
}
|
||||
// The roster changes: one machine added, one gone. An unreadable roster says nothing.
|
||||
nodes := filepath.Join(m.Root, NodesFile)
|
||||
if err := os.WriteFile(nodes, []byte("host-record=node-c.internal,127.0.0.2\nhost-record=node-d.internal,10.77.0.4\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(nodes, []byte("host-record=novox.internal,127.0.0.2\nhost-record=shanks.internal,10.77.0.4\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Tell(m, &a, emit)
|
||||
if got := strings.Join(said, "|"); !strings.Contains(got, "name.added node-d.internal") ||
|
||||
!strings.Contains(got, "name.removed node-a.internal") || len(said) != 2 {
|
||||
if got := strings.Join(said, "|"); !strings.Contains(got, "name.added shanks.internal") ||
|
||||
!strings.Contains(got, "name.removed ace.internal") || len(said) != 2 {
|
||||
t.Fatalf("the roster's change: %v", said)
|
||||
}
|
||||
said = nil
|
||||
|
||||
@@ -32,7 +32,7 @@ func archiveOf(t *testing.T, h tar.Header, body string) string {
|
||||
// Issue 348: route-proxy asked 10.10.0.1 alone while the machine listed both mesh resolvers; its copy
|
||||
// of the file could not be read by anything on the mesh.
|
||||
func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachines(t *testing.T) {
|
||||
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch node-c.internal\noptions timeout:1 attempts:2\n"
|
||||
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch novox.internal\noptions timeout:1 attempts:2\n"
|
||||
f := (&fake{}).on("docker cp route-proxy:/etc/resolv.conf -",
|
||||
Ran{Stdout: archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeReg, Mode: 0o644}, inside)})
|
||||
c := client(f, 1000)
|
||||
@@ -47,7 +47,7 @@ func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachine
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := got["resolv_conf"].(ResolvConf)
|
||||
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "node-c.internal" ||
|
||||
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "novox.internal" ||
|
||||
strings.Join(rc.Options, ",") != "timeout:1,attempts:2" || rc.Raw != inside {
|
||||
t.Fatalf("parsed: %+v", rc)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
|
||||
withBus(t)
|
||||
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42; sleep 0.2; echo "$CHOSEN"`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
t.Setenv("MESH_NODE", "node-b")
|
||||
t.Setenv("MESH_NODE", "g14")
|
||||
var mu sync.Mutex
|
||||
var said []map[string]any
|
||||
before := emit
|
||||
@@ -58,7 +58,7 @@ func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "node-b" {
|
||||
if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "g14" {
|
||||
t.Fatalf("said %v", said)
|
||||
}
|
||||
said = nil
|
||||
@@ -96,7 +96,7 @@ func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) {
|
||||
// **A bundle that started says so**, so the operator channel shows again the answers lost with the one
|
||||
// before.
|
||||
func TestABundleThatStartedSaysSo(t *testing.T) {
|
||||
t.Setenv("MESH_NODE", "node-b")
|
||||
t.Setenv("MESH_NODE", "g14")
|
||||
var said []string
|
||||
before := emit
|
||||
emit = func(event string, body any) error {
|
||||
@@ -105,7 +105,7 @@ func TestABundleThatStartedSaysSo(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { emit = before })
|
||||
sayStarted()
|
||||
if len(said) != 1 || said[0] != "started map[machine:node-b]" {
|
||||
if len(said) != 1 || said[0] != "started map[machine:g14]" {
|
||||
t.Fatalf("%v", said)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,8 +227,7 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
|
||||
for _, never := range []string{"/home/", "jochen", "api_key", ".hal/", "greenclip daemon"} {
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const (
|
||||
// the operator's own addresses. fail2ban keeps a file entry in its own form, not as written: it reads
|
||||
// `file:/var/lib/…` and prints it back as `file://var/lib/…` (server/ipdns.py, FileIPAddrSet.__repr__, in 1.1),
|
||||
// in `get <jail> ignoreip` and in its `Ignore … by …` lines alike. So the path is compared, with fail2ban's own
|
||||
// reading of the slashes after `file:`, never the text (seen on node-a on 2026-10-13: every jail held the list
|
||||
// reading of the slashes after `file:`, never the text (seen on ace on 2026-10-13: every jail held the list
|
||||
// and the watch said none did).
|
||||
func isOurList(word string) bool {
|
||||
path, ok := strings.CutPrefix(strings.TrimSpace(word), "file:")
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// asPrinted is the list's entry as fail2ban 1.1 prints it back, in `get <jail> ignoreip` and in its log: it reads
|
||||
// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as node-a's daemon printed it on
|
||||
// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as ace's daemon printed it on
|
||||
// 2026-10-13.
|
||||
const asPrinted = "file://var/lib/mesh-fail2ban/own-addresses"
|
||||
|
||||
|
||||
@@ -259,8 +259,7 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
|
||||
for _, never := range []string{"/home/", "jochen", "api_key", ".hal/", "greenclip daemon"} {
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
|
||||
@@ -82,8 +82,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
|
||||
for _, never := range []string{"/home/", "jochen", "http", "password", "token"} {
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
|
||||
+15
-9
@@ -22,16 +22,18 @@ Every new head of an open pull request is announced as `pull.updated`, once, and
|
||||
minutes, or when the search fails.
|
||||
- **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
|
||||
open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
|
||||
`mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
|
||||
`mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
|
||||
delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` (or hq's
|
||||
`mesh/repo-check`) with none of them for ten minutes since it first saw it so is a delivery held past its bound
|
||||
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
|
||||
Each of the head's statuses comes with its state and when it was set, so a head whose check started and never
|
||||
answered — a required check pending, or never set beside one that is — is said as a stalled line in the state
|
||||
`unanswered` once that has lasted the delivery table's bound for a check asked and not answered, an hour, counted
|
||||
from its first sight and again only when a check is asked anew (issue 438).
|
||||
- **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own
|
||||
was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep
|
||||
at the latest, and `mesh-controller.conditions` names none for it.
|
||||
was one, and the announcer it fixes never saw it — is announced within a pass of the next sweep at the latest, its
|
||||
`mesh/check` is the checker's to set, and `mesh-controller.conditions` names none for it. (Since issue 1301 this
|
||||
module writes `mesh/merge-gate` only where a base still requires it, and none does once the forge requires
|
||||
`mesh/check`.)
|
||||
- **A pull request or issue number that is not one is refused** before the forge is asked: a tool called with a
|
||||
misnamed argument sent `pulls/NaN`.
|
||||
|
||||
@@ -154,15 +156,19 @@ listed repository:
|
||||
naming the description, and a `merge-check.sh` declaring `# mesh-check-toolchain: go` that vets and runs the unit
|
||||
tests, then the integration tests (`-tags integration`), of every Go module in the tree, and says so and passes on
|
||||
a tree with none. The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges),
|
||||
requiring `mesh/repo-check` and `mesh/merge-gate`, and only then is the repository moved into the organisation.
|
||||
requiring `mesh/check` (ADR 0447 §1, issue 1301), and only then is the repository moved into the organisation.
|
||||
There main's rule is read back and put right once more (the step `rule-after-move`): the forge drops from a merge
|
||||
list a user without access to the repository, and under the setup account `mesh-tools` has none;
|
||||
- one found still under the setup account is finished: the steps it lacks are done, main is never written twice;
|
||||
- one in the organisation is only put right: main's rule narrowed, a missing one of the two statuses added and every
|
||||
status already there kept.
|
||||
- one in the organisation is only put right: main's rule narrowed, `mesh/check` required in place of
|
||||
`mesh/merge-gate` and `mesh/repo-check`, and every other status already there kept. A main that requires
|
||||
`mesh/repo-check` without `mesh/merge-gate` is judged by its own script alone (hq, ADR 0503 §5, until issue 1296)
|
||||
and keeps it; `mesh/check` is never dropped.
|
||||
|
||||
A step the forge refuses stops that repository, and the others go on. A repository taken out of the value is never
|
||||
asked about: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
|
||||
Every other repository of the organisation whose main requires `mesh/merge-gate` has the two old statuses swapped
|
||||
for `mesh/check`, and only its statuses (issue 1301): the switch reaches every repository the merge gate judged, with
|
||||
no list kept by hand. A step the forge refuses stops that repository, and the others go on. Beyond that swap, a
|
||||
repository taken out of the value is never changed: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
|
||||
`repositories-outcome.json`.
|
||||
|
||||
**How a refusal is seen.** `repositories sync` is a run-once process, declared as the module's last resource, so
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tests of issue 1301 (ADR 0447 §1, §5; ADR 0503 §3, §5; lead decisions L4 and L11): main requires `mesh/check`
|
||||
// alone in place of `mesh/merge-gate` and `mesh/repo-check`; a main that requires `mesh/repo-check` without
|
||||
// `mesh/merge-gate` (hq) keeps it; every repository of the organisation whose main requires `mesh/merge-gate` is
|
||||
// switched, listed in the value or not; no other status is dropped.
|
||||
|
||||
func narrowedRule(contexts ...any) map[string]any {
|
||||
return map[string]any{
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []any{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": contexts,
|
||||
"block_admin_merge_override": true,
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S1: a listed repository whose main requires the two old statuses is put right to `mesh/check` in their place,
|
||||
// every other status kept; a second pass changes nothing.
|
||||
func TestListedRepositorySwapsTheTwoStatusesForCheck(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check", "other/x")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"mesh-delivery": {}})
|
||||
got := strs(repo.Rules[mainRule]["status_check_contexts"])
|
||||
if strings.Join(got, ",") != "other/x,mesh/check" {
|
||||
t.Errorf("main requires %v, want other/x and mesh/check", got)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
|
||||
t.Errorf("outcome %+v, want put right", r)
|
||||
}
|
||||
before := len(f.writes())
|
||||
run(t, s, Value{"mesh-delivery": {}})
|
||||
if w := f.writes(); len(w) != before {
|
||||
t.Errorf("a second pass wrote %v", w[before:])
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S1: a repository the program makes requires `mesh/check` alone from its first rule in the organisation on.
|
||||
func TestNewRepositoryRequiresCheckAlone(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{"fresh": {Description: "d"}})
|
||||
rule := f.repos["novox/fresh"].Rules[mainRule]
|
||||
if got := strs(rule["status_check_contexts"]); rule["enable_status_check"] != true || strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("a new repository's main requires %v, want %s alone", got, CheckStatus)
|
||||
}
|
||||
if readme := f.repos["novox/fresh"].Files["README.md"]; !strings.Contains(readme, CheckStatus) ||
|
||||
strings.Contains(readme, "mesh/merge-gate") || strings.Contains(readme, "mesh/repo-check") {
|
||||
t.Errorf("the README names the statuses of before: %q", readme)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S1: mesh/check is never dropped, and a rule that requires it beside a retired status loses the retired one.
|
||||
func TestCheckIsKeptAndARetiredStatusBesideItGoes(t *testing.T) {
|
||||
f := newFake()
|
||||
both := f.add("novox/both", map[string]map[string]any{mainRule: narrowedRule("mesh/check", "mesh/repo-check")}, "main")
|
||||
right := f.add("novox/right", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"both": {}, "right": {}})
|
||||
if got := strs(both.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("both requires %v, want %s alone", got, CheckStatus)
|
||||
}
|
||||
if got := strs(right.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("right requires %v", got)
|
||||
}
|
||||
if result(o, "right").Did != DidUnchanged || result(o, "both").Did != DidPutRight {
|
||||
t.Errorf("outcome %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S1: a listed repository whose main requires nothing is given mesh/check, not the old two.
|
||||
func TestListedRuleRequiringNothingGetsCheck(t *testing.T) {
|
||||
f := newFake()
|
||||
none := f.add("novox/none", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{"none": {}})
|
||||
if got := strs(none.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("none requires %v, want %s", got, CheckStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S2 (ADR 0503 §5): a main that requires mesh/repo-check without mesh/merge-gate is judged by its own script
|
||||
// alone (hq): it keeps mesh/repo-check and is not given mesh/check, listed or not.
|
||||
func TestOwnCheckAloneIsKept(t *testing.T) {
|
||||
f := newFake()
|
||||
hq := f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
lab := f.add("novox/mesh-lab", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"hq": {}})
|
||||
for name, repo := range map[string]*fRepo{"hq": hq, "mesh-lab": lab} {
|
||||
if got := strs(repo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != "mesh/repo-check" {
|
||||
t.Errorf("%s requires %v, want mesh/repo-check alone", name, got)
|
||||
}
|
||||
}
|
||||
if result(o, "hq").Did != DidUnchanged {
|
||||
t.Errorf("hq outcome %+v, want unchanged", result(o, "hq"))
|
||||
}
|
||||
for _, w := range f.writes() {
|
||||
t.Errorf("a repository judged by its own check alone was written: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S3 (L4, L11): a repository the value does not list whose main requires mesh/merge-gate has the two old
|
||||
// statuses swapped for mesh/check, and only its statuses: nothing else of its rule is sent. One that requires
|
||||
// neither, or mesh/repo-check alone, is not written to.
|
||||
func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
|
||||
f := newFake()
|
||||
catalog := f.add("novox/mesh-catalog", map[string]map[string]any{mainRule: {
|
||||
"enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check"},
|
||||
}}, "main")
|
||||
toolsGo := f.add("novox/mesh-tools-go", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
f.add("novox/plain", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
|
||||
f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
f.add("novox/norule", nil, "main")
|
||||
f.add("mesh-admin/elsewhere", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{})
|
||||
|
||||
if got := strs(catalog.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("mesh-catalog requires %v, want %s", got, CheckStatus)
|
||||
}
|
||||
if catalog.Rules[mainRule]["enable_push"] != true {
|
||||
t.Errorf("an unlisted repository's rule was narrowed beyond its statuses: %v", catalog.Rules[mainRule])
|
||||
}
|
||||
if got := strs(toolsGo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("mesh-tools-go requires %v, want %s", got, CheckStatus)
|
||||
}
|
||||
want := []string{
|
||||
"PATCH /api/v1/repos/novox/mesh-catalog/branch_protections/main",
|
||||
"PATCH /api/v1/repos/novox/mesh-tools-go/branch_protections/main",
|
||||
}
|
||||
if got := f.writes(); strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("writes:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
for _, n := range []string{"mesh-catalog", "mesh-tools-go"} {
|
||||
if r := result(o, n); r.Did != DidPutRight {
|
||||
t.Errorf("%s outcome %+v, want put right", n, r)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"plain", "hq", "norule", "elsewhere"} {
|
||||
if r := result(o, n); r.Name != "" {
|
||||
t.Errorf("%s is in the outcome though nothing was done: %+v", n, r)
|
||||
}
|
||||
}
|
||||
before := len(f.writes())
|
||||
run(t, s, Value{})
|
||||
if w := f.writes(); len(w) != before {
|
||||
t.Errorf("a second pass wrote %v", w[before:])
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S3: the organisation's repositories are read page by page, so one past the first page is switched too.
|
||||
func TestUnlistedRepositoriesAreReadPastTheFirstPage(t *testing.T) {
|
||||
f := newFake()
|
||||
f.pageSize = 2
|
||||
for _, n := range []string{"a", "b", "c", "d", "e"} {
|
||||
f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check")}, "main")
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{})
|
||||
for _, n := range []string{"a", "b", "c", "d", "e"} {
|
||||
if got := strs(f.repos["novox/"+n].Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("%s requires %v", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S3: a refused swap is said with the repository and its step, and the pass goes on to the next.
|
||||
func TestRefusedSwapIsSaid(t *testing.T) {
|
||||
f := newFake()
|
||||
f.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
f.refuse = func(method, path string) (int, string) {
|
||||
if method == "PATCH" && strings.Contains(path, "/novox/a/") {
|
||||
return 403, "no"
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{})
|
||||
if r := result(o, "a"); r.Did != DidRefused || r.Step != StepSwap {
|
||||
t.Errorf("a: %+v, want refused at %s", r, StepSwap)
|
||||
}
|
||||
if r := result(o, "b"); r.Did != DidPutRight {
|
||||
t.Errorf("b: %+v, want put right", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S3: the organisation's list refused is said, and the listed repositories are still done.
|
||||
func TestOrganisationListRefusedIsSaid(t *testing.T) {
|
||||
f := newFake()
|
||||
f.refuse = func(method, path string) (int, string) {
|
||||
if strings.HasPrefix(path, "/api/v1/orgs/") {
|
||||
return 500, "down"
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"x": {}})
|
||||
if result(o, "x").Did != DidMade {
|
||||
t.Errorf("x: %+v", result(o, "x"))
|
||||
}
|
||||
refusedList := false
|
||||
for _, r := range o.Refused() {
|
||||
refusedList = refusedList || r.Step == StepList
|
||||
}
|
||||
if !refusedList {
|
||||
t.Errorf("the refused list is not in the outcome: %+v", o)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -43,6 +44,8 @@ type fakeForge struct {
|
||||
calls []string // "METHOD /path"
|
||||
// refuse answers a call with a status and a message when it returns a status above zero.
|
||||
refuse func(method, path string) (int, string)
|
||||
// pageSize is how many repositories one page of the organisation's list holds; 50 when zero.
|
||||
pageSize int
|
||||
}
|
||||
|
||||
func newFake() *fakeForge { return &fakeForge{repos: map[string]*fRepo{}} }
|
||||
@@ -84,6 +87,7 @@ var (
|
||||
branchPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/branches/([^/]+)$`)
|
||||
filesPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/contents$`)
|
||||
movePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/transfer$`)
|
||||
orgRepos = regexp.MustCompile(`^/api/v1/orgs/([^/]+)/repos$`)
|
||||
)
|
||||
|
||||
func strs(v any) []string {
|
||||
@@ -167,6 +171,31 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case p == "/api/v1/version":
|
||||
reply(200, map[string]string{"version": "1.27"})
|
||||
case orgRepos.MatchString(p) && r.Method == http.MethodGet:
|
||||
owner := orgRepos.FindStringSubmatch(p)[1]
|
||||
var names []string
|
||||
for full := range f.repos {
|
||||
if o, n, _ := strings.Cut(full, "/"); o == owner {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
size := f.pageSize
|
||||
if size == 0 {
|
||||
size = 50
|
||||
}
|
||||
if l, _ := strconv.Atoi(r.URL.Query().Get("limit")); l > 0 && l < size {
|
||||
size = l
|
||||
}
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for i := (page - 1) * size; i < len(names) && i < page*size; i++ {
|
||||
out = append(out, map[string]any{"name": names[i], "full_name": owner + "/" + names[i]})
|
||||
}
|
||||
reply(200, out)
|
||||
case p == "/api/v1/user/repos" && r.Method == http.MethodPost:
|
||||
name := fmt.Sprint(in["name"])
|
||||
if f.repos[admin+"/"+name] != nil {
|
||||
@@ -312,12 +341,3 @@ func result(o Outcome, name string) Result {
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
func has(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import "strings"
|
||||
const MergeCheck = `#!/bin/sh
|
||||
# mesh-check-toolchain: go
|
||||
#
|
||||
# This repository's own check, mesh/repo-check (novox/hq ADR 0238 §4), written with its first commit (ADR 0428 §4):
|
||||
# This repository's own check (novox/hq ADR 0238 §4), run by the checker within mesh/check (ADR 0447), written with its first commit (ADR 0428 §4):
|
||||
# every Go module in the tree is vetted, its unit tests run, and then its integration tests (-tags integration).
|
||||
# A tree with no Go module has nothing to test, and the check says so and passes.
|
||||
set -eu
|
||||
@@ -37,7 +37,7 @@ func FirstCommit(name string, r Repo) map[string]string {
|
||||
}
|
||||
readme := "# " + name + "\n\n" + purpose + "\n\n" +
|
||||
"Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: " +
|
||||
"a change reaches it by a pull request whose `mesh/repo-check` (this repository's `merge-check.sh`) and " +
|
||||
"`mesh/merge-gate` passed.\n"
|
||||
"a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto " +
|
||||
"main, this repository's `merge-check.sh` among its tests (novox/hq ADR 0447).\n"
|
||||
return map[string]string{"README.md": readme, "merge-check.sh": MergeCheck}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// again whenever the value changes: it reads the file the mesh renders from that value and, with the setup login,
|
||||
// makes every listed repository that does not exist under that login's own account, protects its main, writes
|
||||
// main's first commit, narrows main's rule and only then moves it into the organisation; one that exists only has
|
||||
// its main rule put right. A repository the value does not list is never touched. It keeps what it did, per
|
||||
// its main rule put right, requiring mesh/check in place of the two statuses it replaces (issue 1301). A repository
|
||||
// the value does not list is touched only to swap those two for mesh/check when its main requires mesh/merge-gate. It keeps what it did, per
|
||||
// repository, in the outcome file, says each change on stdout in one line and never a secret, and exits 1 when a
|
||||
// step was refused, its last stderr lines naming each (outcome.go Report): the node-engine's gate on a run-once
|
||||
// step (ADR 0136) reports it and runs it again on every apply until it passes.
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1141/AC2: a narrowed main rule that still lets an administrator override is not right: it is put right, its
|
||||
// statuses kept, and a second pass changes nothing.
|
||||
// statuses kept (the two old ones as mesh/check, issue 1301), and a second pass changes nothing.
|
||||
func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: {
|
||||
@@ -33,10 +33,8 @@ func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
|
||||
if rule["block_admin_merge_override"] != true {
|
||||
t.Errorf("the administrator's override is still allowed: %v", rule)
|
||||
}
|
||||
for _, c := range []string{"mesh/merge-gate", "mesh/repo-check", "other/x"} {
|
||||
if !has(strs(rule["status_check_contexts"]), c) {
|
||||
t.Errorf("a status was dropped: %v", rule["status_check_contexts"])
|
||||
}
|
||||
if c := strs(rule["status_check_contexts"]); strings.Join(c, ",") != "other/x,mesh/check" {
|
||||
t.Errorf("statuses %v, want other/x kept and mesh/check for the two it replaces (issue 1301)", c)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
|
||||
t.Errorf("outcome %+v, want put right", r)
|
||||
|
||||
@@ -26,9 +26,47 @@ const (
|
||||
MainBranch = "main"
|
||||
)
|
||||
|
||||
// RequiredStatuses are what main requires in every listed repository from its first commit (ADR 0428 §5): the
|
||||
// repository's own check and the module graph's gate. A status already on the rule is kept beside them.
|
||||
var RequiredStatuses = []string{"mesh/merge-gate", "mesh/repo-check"}
|
||||
// CheckStatus is the one status main requires (ADR 0447 §1, issue 1301): the checker's verdict on a pull request's
|
||||
// head merged onto its trunk's head. It replaces GateStatus and OwnCheckStatus, and is never dropped.
|
||||
const CheckStatus = "mesh/check"
|
||||
|
||||
// GateStatus and OwnCheckStatus are the two statuses CheckStatus replaces: the controller's merge gate and the
|
||||
// repository's own merge-check.sh (ADR 0238 §4, ADR 0428 §5 before ADR 0447 §1).
|
||||
const (
|
||||
GateStatus = "mesh/merge-gate"
|
||||
OwnCheckStatus = "mesh/repo-check"
|
||||
)
|
||||
|
||||
// wanted is what a main rule requiring have should require: have with GateStatus and OwnCheckStatus replaced by
|
||||
// CheckStatus, every other status kept in its order (ADR 0447 §1). A rule requiring OwnCheckStatus with neither
|
||||
// GateStatus nor CheckStatus is judged by its own script alone, as hq is until issue 1296 settles how hq is
|
||||
// checked (ADR 0503 §5): the checker writes no status there (ADR 0484 §8), so it is left as it is. This reads the
|
||||
// rule, not a list of names (lead decision L11 of R4e).
|
||||
func wanted(have []string) []string {
|
||||
if !has(have, GateStatus) && !has(have, CheckStatus) && has(have, OwnCheckStatus) {
|
||||
return have
|
||||
}
|
||||
out := []string{}
|
||||
for _, s := range have {
|
||||
if s != GateStatus && s != OwnCheckStatus && s != CheckStatus {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return append(out, CheckStatus)
|
||||
}
|
||||
|
||||
func has(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sameList(a, b []string) bool {
|
||||
return strings.Join(a, "\x00") == strings.Join(b, "\x00")
|
||||
}
|
||||
|
||||
// The steps of one repository, as an outcome names a refused one (ADR 0428 §6).
|
||||
const (
|
||||
@@ -42,6 +80,10 @@ const (
|
||||
// StepRuleAfterMove reads main's rule back once the repository is in the organisation and puts it right: the
|
||||
// forge drops from a merge whitelist a user without access, and under the setup account the tools' have none.
|
||||
StepRuleAfterMove = "rule-after-move"
|
||||
// StepList reads the organisation's repositories, for those the value does not list (issue 1301).
|
||||
StepList = "list"
|
||||
// StepSwap swaps the two old statuses for CheckStatus on a repository the value does not list (issue 1301).
|
||||
StepSwap = "swap"
|
||||
)
|
||||
|
||||
// What a pass did with one repository.
|
||||
@@ -157,9 +199,73 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) {
|
||||
}
|
||||
out.Repositories = append(out.Repositories, r)
|
||||
}
|
||||
for _, r := range s.switchUnlisted(ctx, v) {
|
||||
if r.Did == DidRefused {
|
||||
s.say("refused: %s/%s at the step %s: %s", s.Org, r.Name, r.Step, r.Said)
|
||||
} else {
|
||||
s.say("%s: %s/%s (%s in place of %s and %s)", r.Did, s.Org, r.Name, CheckStatus, GateStatus, OwnCheckStatus)
|
||||
}
|
||||
out.Repositories = append(out.Repositories, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// orgPage is how many repositories one read of the organisation's list asks for.
|
||||
const orgPage = 50
|
||||
|
||||
// switchUnlisted swaps the two old statuses for CheckStatus on main's rule of every repository of the organisation
|
||||
// the value does not list whose main requires GateStatus (issue 1301, lead decisions L4 and L11 of R4e): the
|
||||
// repositories the merge gate judges are switched in the delivery that has the checker write mesh/check (ADR 0447
|
||||
// §5), with no list kept by hand. Only the statuses of a rule covering main are sent; nothing else of a rule the
|
||||
// value does not hold is changed (ADR 0428 §7 for all of it but the statuses the mesh itself requires). A main that
|
||||
// does not require GateStatus gets no call. It answers only what it did: a repository switched, or refused.
|
||||
func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
|
||||
var names []string
|
||||
for page := 1; ; page++ {
|
||||
var repos []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
q := "?limit=" + fmt.Sprint(orgPage) + "&page=" + fmt.Sprint(page)
|
||||
if err := s.Forge.GetJSON(ctx, "/api/v1/orgs/"+url.PathEscape(s.Org)+"/repos"+q, &repos); err != nil {
|
||||
return []Result{refused("(the organisation's repositories)", StepList, err)}
|
||||
}
|
||||
for _, r := range repos {
|
||||
if _, listed := v[r.Name]; !listed && r.Name != "" {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
}
|
||||
if len(repos) == 0 || page > 1000 {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
var out []Result
|
||||
for _, n := range names {
|
||||
rules, err := s.rules(ctx, s.Org, n)
|
||||
if err != nil {
|
||||
out = append(out, refused(n, StepRead, err))
|
||||
continue
|
||||
}
|
||||
var did *Result
|
||||
for _, r := range rules {
|
||||
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || !has(required(r), GateStatus) {
|
||||
continue
|
||||
}
|
||||
body := map[string]any{"enable_status_check": true, "status_check_contexts": wanted(required(r))}
|
||||
if err := s.Forge.SendJSON(ctx, http.MethodPatch,
|
||||
repoAPI(s.Org, n)+"/branch_protections/"+url.PathEscape(ruleName(r)), body, nil); err != nil {
|
||||
did = &Result{Name: n, Did: DidRefused, Step: StepSwap, Said: err.Error()}
|
||||
break
|
||||
}
|
||||
did = &Result{Name: n, Did: DidPutRight}
|
||||
}
|
||||
if did != nil {
|
||||
out = append(out, *did)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitForForge is cmd/agent-teams' (teams.go waitForForge): the forge starting with the module answers late.
|
||||
func (s *Syncer) waitForForge(ctx context.Context) error {
|
||||
deadline := time.Now().Add(s.WaitFor)
|
||||
@@ -309,7 +415,7 @@ func strList(v any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// statuses is what a rule requires, with RequiredStatuses added and none dropped.
|
||||
// statuses is what the rules require together, as wanted makes it.
|
||||
func statuses(rules ...rule) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
@@ -320,16 +426,19 @@ func statuses(rules ...rule) []string {
|
||||
}
|
||||
}
|
||||
for _, r := range rules {
|
||||
if r != nil && r["enable_status_check"] == true {
|
||||
for _, s := range strList(r["status_check_contexts"]) {
|
||||
add(s)
|
||||
}
|
||||
for _, s := range required(r) {
|
||||
add(s)
|
||||
}
|
||||
}
|
||||
for _, s := range RequiredStatuses {
|
||||
add(s)
|
||||
return wanted(out)
|
||||
}
|
||||
|
||||
// required is what a rule requires: its statuses when it checks them, else none.
|
||||
func required(r rule) []string {
|
||||
if r == nil || r["enable_status_check"] != true {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
return strList(r["status_check_contexts"])
|
||||
}
|
||||
|
||||
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges,
|
||||
@@ -344,7 +453,7 @@ func narrowed(r rule, toolsAccount string, also ...rule) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// isRight says whether a rule already is narrowed and requires both statuses.
|
||||
// isRight says whether a rule already is narrowed and requires what wanted makes of it.
|
||||
func isRight(r rule, toolsAccount string) bool {
|
||||
if r["enable_push"] != false || r["enable_push_whitelist"] == true || r["enable_merge_whitelist"] != true {
|
||||
return false
|
||||
@@ -352,26 +461,15 @@ func isRight(r rule, toolsAccount string) bool {
|
||||
if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount {
|
||||
return false
|
||||
}
|
||||
return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"])) &&
|
||||
have := strList(r["status_check_contexts"])
|
||||
return r["enable_status_check"] == true && len(have) > 0 && sameList(have, wanted(have)) &&
|
||||
r["block_admin_merge_override"] == true
|
||||
}
|
||||
|
||||
func hasAll(have []string) bool {
|
||||
for _, want := range RequiredStatuses {
|
||||
found := false
|
||||
for _, h := range have {
|
||||
found = found || h == want
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires.
|
||||
// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires but
|
||||
// the two CheckStatus replaces (ADR 0447 §1).
|
||||
// A repository with no rule named main gets one, carrying what a glob rule covering main asks (client.ts
|
||||
// ensureMainMerges, issue 948). It never takes a status away and never unprotects (ADR 0428 §5, §7).
|
||||
// ensureMainMerges, issue 948). It takes away no other status and never unprotects (ADR 0428 §5, §7).
|
||||
func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) {
|
||||
rules, err := s.rules(ctx, owner, name)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
const mainRule = "main"
|
||||
|
||||
// 1124/AC2: a listed repository that does not exist is made under the setup account, given main's rule letting that
|
||||
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/repo-check (and
|
||||
// mesh/merge-gate, 1124/AC5), and only then moved into the organisation. Nothing reaches the organisation before the
|
||||
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/check (1124/AC5
|
||||
// as issue 1301 changes it), and only then moved into the organisation. Nothing reaches the organisation before the
|
||||
// narrowed rule, and nothing else is pushed to main.
|
||||
func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
f := newFake()
|
||||
@@ -67,8 +67,8 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
t.Errorf("main's merge whitelist is not the tools' account alone: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") {
|
||||
t.Errorf("main does not require mesh/repo-check and mesh/merge-gate (1124/AC5): %v", rule)
|
||||
if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus {
|
||||
t.Errorf("main does not require mesh/check alone (1124/AC5, issue 1301): %v", rule)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidMade || r.Step != "" {
|
||||
t.Errorf("outcome %+v, want made", r)
|
||||
@@ -87,7 +87,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
|
||||
// 1124/AC2: the forge drops from a merge whitelist a user without access to the repository, and under the setup
|
||||
// account the tools' account has none; so once the repository is in the organisation its main rule is read back and
|
||||
// put right: the tools' account the only one that merges, both statuses required.
|
||||
// put right: the tools' account the only one that merges, mesh/check required.
|
||||
func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
@@ -104,8 +104,8 @@ func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
|
||||
t.Errorf("after the move main takes a push: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") {
|
||||
t.Errorf("after the move main does not require both statuses: %v", rule)
|
||||
if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus {
|
||||
t.Errorf("after the move main does not require mesh/check alone: %v", rule)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidMade {
|
||||
t.Errorf("outcome %+v, want made", r)
|
||||
@@ -136,7 +136,8 @@ func TestAdminRuleLetsTheSetupLoginAlonePush(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1124/AC3: a listed repository that exists in the organisation is not made again, only its main rule put right;
|
||||
// a rule still letting the setup login push is narrowed, its statuses kept (1124/AC5).
|
||||
// a rule still letting the setup login push is narrowed, its statuses kept but the two mesh/check replaces (1124/AC5,
|
||||
// issue 1301).
|
||||
func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
|
||||
f := newFake()
|
||||
hq := f.add("novox/hq", map[string]map[string]any{mainRule: {
|
||||
@@ -157,14 +158,11 @@ func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
|
||||
t.Errorf("hq's rule not narrowed: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
for _, c := range []string{"other/x", "mesh/merge-gate", "mesh/repo-check"} {
|
||||
if !has(checks, c) {
|
||||
t.Errorf("hq's rule lacks %s: %v", c, checks)
|
||||
}
|
||||
if strings.Join(checks, ",") != "other/x,mesh/check" {
|
||||
t.Errorf("hq's rule requires %v, want other/x and mesh/check", checks)
|
||||
}
|
||||
made := bare.Rules[mainRule]
|
||||
if made == nil || made["enable_push"] != false || !has(strs(made["status_check_contexts"]), "mesh/repo-check") ||
|
||||
!has(strs(made["status_check_contexts"]), "mesh/merge-gate") {
|
||||
if made == nil || made["enable_push"] != false || strings.Join(strs(made["status_check_contexts"]), ",") != CheckStatus {
|
||||
t.Errorf("bare's rule: %v", made)
|
||||
}
|
||||
if hq.Commits != 0 || bare.Commits != 0 {
|
||||
@@ -214,8 +212,8 @@ func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC5: putting a rule right adds a missing one of mesh/repo-check and mesh/merge-gate and keeps every status
|
||||
// already there; no pass drops one.
|
||||
// 1124/AC5 as issue 1301 changes it: putting a rule right adds mesh/check when the rule requires mesh/merge-gate,
|
||||
// and keeps every other status there; a rule requiring mesh/repo-check alone keeps it (ADR 0503 §5).
|
||||
func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
|
||||
f := newFake()
|
||||
only := f.add("novox/only", map[string]map[string]any{mainRule: {
|
||||
@@ -224,15 +222,15 @@ func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
|
||||
}}, "main")
|
||||
more := f.add("novox/more", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "mesh/delivery"},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/delivery", "mesh/check"},
|
||||
"block_admin_merge_override": true, // right since issue 1141
|
||||
}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"only": {}, "more": {}})
|
||||
if c := strs(only.Rules[mainRule]["status_check_contexts"]); !has(c, "mesh/merge-gate") || !has(c, "mesh/repo-check") {
|
||||
if c := strs(only.Rules[mainRule]["status_check_contexts"]); strings.Join(c, ",") != "mesh/repo-check" {
|
||||
t.Errorf("only: %v", c)
|
||||
}
|
||||
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 3 || !has(c, "mesh/delivery") {
|
||||
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 2 || !has(c, "mesh/delivery") {
|
||||
t.Errorf("more lost a status: %v", c)
|
||||
}
|
||||
for _, w := range f.writes() {
|
||||
@@ -314,7 +312,8 @@ func TestRefusedStepStopsThatRepositoryOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC7: a repository taken out of the value gets no call at all.
|
||||
// 1124/AC7: a repository taken out of the value is not changed: its rules are read (issue 1301 switches one whose
|
||||
// main requires mesh/merge-gate), nothing is written, and one still under the setup account is not asked about.
|
||||
func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
|
||||
f := newFake()
|
||||
f.add("novox/old", map[string]map[string]any{mainRule: {"enable_push": true}}, "main")
|
||||
@@ -323,7 +322,7 @@ func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
|
||||
run(t, s, Value{})
|
||||
run(t, s, Value{"new": {}})
|
||||
for _, c := range f.all() {
|
||||
if strings.Contains(c, "old") {
|
||||
if strings.Contains(c, "older") || (strings.Contains(c, "old") && !strings.HasPrefix(c, "GET ")) {
|
||||
t.Errorf("a repository out of the value was asked: %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import { promisify } from "node:util";
|
||||
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import type { CommitStatus } from "./client.js";
|
||||
import { checkName } from "./guard.js";
|
||||
import { CHECK_STATUS } from "./pulls.js";
|
||||
|
||||
export { CHECK_STATUS };
|
||||
|
||||
/** How a command is run: docker on the forge's machine, or a test's. */
|
||||
export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>;
|
||||
@@ -91,11 +94,18 @@ const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
/** The merge check's contexts (pulls.ts): a branch's protection may require them. */
|
||||
const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]);
|
||||
|
||||
|
||||
/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a
|
||||
* bounded description. */
|
||||
export function deliveryStatus(context: string, state: string, description: string, target?: string) {
|
||||
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
|
||||
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
|
||||
// mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any
|
||||
// state, as issue 293 closed for the two before it. The forge bridge writes it through gitea_check_status, a tool
|
||||
// only its account may call (issue 1301).
|
||||
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`);
|
||||
}
|
||||
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
|
||||
// them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq
|
||||
// issue 293). Pending is no verdict: it is what this holder says on every new head until the verdict comes,
|
||||
@@ -126,7 +136,7 @@ export interface StatusWriters {
|
||||
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||
return {
|
||||
name: "gitea_commit_status",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: 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.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
@@ -153,3 +163,48 @@ 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,7 +212,8 @@ export class GateWriter {
|
||||
let since = h.pendingAt;
|
||||
if (since === undefined) {
|
||||
const gate = await this.forge.gateOf(owner, repo, sha);
|
||||
if (gate && gate.state !== "pending") return;
|
||||
// No gate on the forge: none was written (its base does not require it, issue 1301), and nothing is said.
|
||||
if (!gate || gate.state !== "pending") return;
|
||||
since = gate?.updated_at ? Date.parse(gate.updated_at) : undefined;
|
||||
}
|
||||
if (since !== undefined && at.getTime() < since - CLOCK_SLACK_MS) return;
|
||||
|
||||
+22
-11
@@ -12,7 +12,9 @@
|
||||
// Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head
|
||||
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
|
||||
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
|
||||
// saying why when the gate is not a pass or the repository's own check failed.
|
||||
// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base
|
||||
// still requires are written (issue 1301): main requires `mesh/check` — the checker's, written by its forge bridge
|
||||
// through gitea_check_status — everywhere but hq, whose main requires `mesh/repo-check` alone (ADR 0503 §5).
|
||||
//
|
||||
// Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of,
|
||||
// which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate.
|
||||
@@ -38,12 +40,12 @@
|
||||
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
import { commitStatusTool } from "./delivery.js";
|
||||
import { checkStatusTool, commitStatusTool } from "./delivery.js";
|
||||
import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, statusesFor, type Announced, type Checked,
|
||||
repoCheckFacts, reposToLook } from "./pulls.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained,
|
||||
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js";
|
||||
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
|
||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
|
||||
@@ -247,8 +249,11 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
|
||||
// Said, so an operator knows the mesh was asked to check it.
|
||||
console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`);
|
||||
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
|
||||
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446).
|
||||
await gates.pending(repo.owner, repo.name, sha);
|
||||
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). Only where the base
|
||||
// still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's, and hq's requires
|
||||
// mesh/repo-check alone; a rule that cannot be read is said pending, as before.
|
||||
const rule = await client.appliedProtection(repo.owner, repo.name, String(pull.base || "main")).catch(() => undefined);
|
||||
if (writesGate(requiredOf(rule))) await gates.pending(repo.owner, repo.name, sha);
|
||||
return true;
|
||||
}
|
||||
type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
|
||||
@@ -257,7 +262,7 @@ type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
|
||||
async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
|
||||
try {
|
||||
const checks = await client.headChecks(repo.owner, repo.name, String(pull.head_sha));
|
||||
return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks;
|
||||
return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks || CHECK_STATUS in checks;
|
||||
} catch {
|
||||
return false; // the forge cannot say: announced, and the bus's message id collapses it if it was
|
||||
}
|
||||
@@ -394,16 +399,20 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
|
||||
// (novox/hq ADR 0323 §4): a check asked before the trunk moved. It is said so, pending with the reason, in place of
|
||||
// its pass or failure; a trunk that cannot be read lets the verdict stand, and the merge tool compares the commits.
|
||||
const trunk = base ?? c.plan?.base;
|
||||
// Only the statuses the base still requires are written (issue 1301): on hq, mesh/repo-check alone.
|
||||
const required = trunk ? requiredOf(await client.appliedProtection(c.owner, c.repo, trunk).catch(() => undefined)) : undefined;
|
||||
const kept = maintained(required);
|
||||
if (c.trunk && trunk) {
|
||||
const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined);
|
||||
if (head && !sameCommit(c.trunk, head)) {
|
||||
const description = outOfDateWords(c.trunk, head);
|
||||
for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]) {
|
||||
for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]
|
||||
.filter((x) => kept.includes(x))) {
|
||||
await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description,
|
||||
...(target ? { target_url: target } : {}) });
|
||||
}
|
||||
// Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date.
|
||||
const said = commentFor(c, await repoCheckFacts(client, c, trunk));
|
||||
const said = commentFor(c, await repoCheckFacts(client, c, trunk), required);
|
||||
if (c.number && !composedGate(c)) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description));
|
||||
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` +
|
||||
`${description}`);
|
||||
@@ -412,10 +421,10 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
|
||||
}
|
||||
const facts = await repoCheckFacts(client, c, base ?? c.plan?.base);
|
||||
const gateNow = composedGate(c) ? (await client.headChecks(c.owner, c.repo, c.commit).catch(() => ({} as Record<string, { description?: string }>)))[CHECK_CONTEXT]?.description : undefined;
|
||||
for (const status of statusesFor(c, facts, gateNow)) {
|
||||
for (const status of statusesFor(c, facts, gateNow, required)) {
|
||||
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
|
||||
}
|
||||
const comment = composedGate(c) ? null : commentFor(c, facts);
|
||||
const comment = composedGate(c) ? null : commentFor(c, facts, required);
|
||||
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
|
||||
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
|
||||
}
|
||||
@@ -477,6 +486,8 @@ if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
gates,
|
||||
}), checkStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
}), 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
|
||||
|
||||
+41
-10
@@ -4,13 +4,18 @@
|
||||
// when a verdict is judged against an earlier trunk commit than the trunk's head, the tool asks the head's check and
|
||||
// puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked.
|
||||
// Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain.
|
||||
//
|
||||
// **It judges on the status the base requires** (issue 1301; ADR 0447 §1, §4; ADR 0503 §5): mesh/check, the checker's,
|
||||
// whose line names the trunk it judged ("pass <head12> on <trunk12>"); where a base does not require it, the controller's
|
||||
// status it still requires (hq's mesh/repo-check), as before. A mesh/check of another trunk is refused and nothing is
|
||||
// asked: the checker sets it out of date on the trunk's move and checks it again.
|
||||
|
||||
import { announce } from "./announce.js";
|
||||
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import type { CommitStatus, GiteaClient } from "./client.js";
|
||||
import { mergeableUnder, newestByContext } from "./checks.js";
|
||||
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
|
||||
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
|
||||
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
|
||||
|
||||
@@ -32,7 +37,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
return {
|
||||
name: "gitea_merge_pull_request",
|
||||
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " +
|
||||
"and every status its base branch's protection requires succeeded there (mesh/merge-gate, mesh/repo-check), " +
|
||||
"and every status its base branch's protection requires succeeded there (mesh/check; hq's mesh/repo-check), " +
|
||||
"judged against the trunk's head as it is now, optionally deleting the source branch afterwards. Refused " +
|
||||
"otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " +
|
||||
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
|
||||
@@ -108,11 +113,33 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
// head makes — never again while one is on its way. The caller asks again at the reviewed head once it passes.
|
||||
const trunkHead = await gitea.branchHead(owner, repo, pull.base);
|
||||
const checks = await gitea.headChecks(owner, repo, pull.head_sha);
|
||||
const at = verdictAtTrunk(checks, trunkHead);
|
||||
const required = requiredOf(rule) ?? [];
|
||||
if (required.includes(CHECK_STATUS)) {
|
||||
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
|
||||
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
|
||||
const check = checks[CHECK_STATUS];
|
||||
const at = verdictAtTrunk(checks, trunkHead, CHECK_STATUS);
|
||||
if (!check || check.state === "pending") {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ` +
|
||||
`${check ? `pending (${check.description ?? ""})` : "not set yet"}; the checker answers it. Ask again at ` +
|
||||
`${twelve(pull.head_sha)} once it passes against ${pull.base}'s head ${twelve(trunkHead)}. Nothing was merged`);
|
||||
}
|
||||
if (!at.current) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} ` +
|
||||
`${at.judged ? `was judged on trunk ${twelve(at.judged)}` : "names no trunk commit it was judged on"}, and ` +
|
||||
`${pull.base} is now ${twelve(trunkHead)}. The checker checks it again against the trunk's head (novox/hq ` +
|
||||
`ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`);
|
||||
}
|
||||
}
|
||||
// Where the base still requires the controller's statuses (hq's mesh/repo-check; mesh/merge-gate only on a
|
||||
// revert), the first it requires judges, as before (ADR 0323 §5).
|
||||
const kept = maintained(required);
|
||||
const judge = kept[0] ?? CHECK_CONTEXT;
|
||||
const at = kept.length ? verdictAtTrunk(checks, trunkHead, judge) : { current: true, onItsWay: false };
|
||||
if (!at.current) {
|
||||
// A verdict put out of date by a sibling's move says so in its own words (ADR 0382 §5); it is asked again as a
|
||||
// trunk's move is, and a failed ask puts those words back, not the trunk's.
|
||||
const siblingWords = siblingOutOfDate(checks[CHECK_CONTEXT]?.description) ? checks[CHECK_CONTEXT]?.description : undefined;
|
||||
const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined;
|
||||
const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})`
|
||||
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
|
||||
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
|
||||
@@ -120,21 +147,22 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
// **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own
|
||||
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
|
||||
// refused the same way for ever. The composed check is mesh-delivery's to ask; this says whose verdict is awaited.
|
||||
if (waitsForComposed(checks[CHECK_CONTEXT])) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[CHECK_CONTEXT]?.description}; the ` +
|
||||
if (waitsForComposed(checks[judge])) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[judge]?.description}; the ` +
|
||||
`verdict that names its trunk commit is its group's composed check's, and mesh-delivery asks it, this tool asks no check ` +
|
||||
`(main is now ${now}). Ask again once the group's composed check has passed. Nothing was merged`);
|
||||
}
|
||||
if (at.onItsWay) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
||||
`${now}. Its check is already asked and not yet answered (${checks[CHECK_CONTEXT]?.description}), so it is not ` +
|
||||
`${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` +
|
||||
`asked again; ${took}. Ask again at ${head} once both statuses pass: this tool never merges by itself ` +
|
||||
`(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`);
|
||||
}
|
||||
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
|
||||
// announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather
|
||||
// than wait for ever on a check nobody asked.
|
||||
const contexts = [CHECK_CONTEXT, ...(checks[REPO_CHECK_CONTEXT] ? [REPO_CHECK_CONTEXT] : [])];
|
||||
// The statuses the base requires of the controller's (issue 1301): hq's mesh/repo-check alone.
|
||||
const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT]));
|
||||
// Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new
|
||||
// check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older.
|
||||
const say = async (description: string) => {
|
||||
@@ -167,9 +195,12 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
// **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6): the
|
||||
// siblings its verdict names are the holder's record (siblings.ts); a status that says it read siblings with no record
|
||||
// of them is no verdict to merge on, never read as none.
|
||||
const heard = readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
|
||||
// Under mesh/check (issue 1301) the group is the checker's one verdict on every member's head (ADR 0447 §2) and the
|
||||
// Approve's heads above; a record the controller's verdicts left before the switch judges nothing there.
|
||||
const heard = required.includes(CHECK_STATUS) ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
|
||||
const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : [];
|
||||
if (!siblings.length && namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) {
|
||||
if (!siblings.length && !required.includes(CHECK_STATUS) &&
|
||||
namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict read siblings, and which ones is not held ` +
|
||||
`here, so the group cannot be weighed. Ask its check again at ${twelve(pull.head_sha)}. Nothing was merged`);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
},
|
||||
"repositories": {
|
||||
"type": "object",
|
||||
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/repo-check and mesh/merge-gate, and only then moved into the organisation; one that exists only has main's rule put right, its statuses kept. A repository taken out of the value is never touched.",
|
||||
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's merge check —
|
||||
// `mesh/merge-gate`, the module graph's gate, and `mesh/repo-check`, the repository's own tests — before it
|
||||
// may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
|
||||
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's check — `mesh/check`,
|
||||
// the checker's verdict on the head merged onto its trunk (ADR 0447 §1; hq's `mesh/repo-check` until issue 1296) —
|
||||
// before it may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
|
||||
|
||||
import { TOOLS_ACCOUNT } from "./tools-account.js";
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface BranchProtection {
|
||||
/** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's
|
||||
* default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */
|
||||
export interface ProtectionWanted {
|
||||
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/merge-gate. Empty: none. */
|
||||
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/check. Empty: none. */
|
||||
statusChecks: string[];
|
||||
/** Whether a person may push to the branch directly. */
|
||||
push?: boolean;
|
||||
|
||||
+45
-7
@@ -19,6 +19,34 @@ export const CHECK_CONTEXT = "mesh/merge-gate";
|
||||
/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */
|
||||
export const REPO_CHECK_CONTEXT = "mesh/repo-check";
|
||||
|
||||
/** The one status main requires (novox/hq ADR 0447 §1, issue 1301): the checker's verdict, written by its forge bridge
|
||||
* through gitea_check_status. Its line names the head and the trunk commit it judged: "pass <head12> on <trunk12> …". */
|
||||
export const CHECK_STATUS = "mesh/check";
|
||||
|
||||
/** The statuses a branch's rule requires, from the rule the forge applies to it: none when it is unprotected or checks
|
||||
* no status; undefined when the rule could not be read. */
|
||||
export function requiredOf(rule: { enable_status_check?: boolean; status_check_contexts?: string[] } | null | undefined): string[] | undefined {
|
||||
if (rule === undefined) return undefined;
|
||||
if (!rule || !rule.enable_status_check) return [];
|
||||
return (rule.status_check_contexts ?? []).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* **The controller's statuses are written only where a base still requires them** (issue 1301; ADR 0503 §3, §5): of
|
||||
* mesh/merge-gate and mesh/repo-check, those the base requires — hq's mesh/repo-check alone once the forge requires
|
||||
* mesh/check everywhere else, so no mesh/merge-gate stands on a head that nothing will set. A base whose rule could not
|
||||
* be read keeps both, as before: a status nothing requires blocks nothing, and one that is required is never left out.
|
||||
*/
|
||||
export function maintained(required: string[] | undefined): string[] {
|
||||
const both = [CHECK_CONTEXT, REPO_CHECK_CONTEXT];
|
||||
return required === undefined ? both : both.filter((c) => required.includes(c));
|
||||
}
|
||||
|
||||
/** Whether mesh/merge-gate is written on a head of this base (maintained). */
|
||||
export function writesGate(required: string[] | undefined): boolean {
|
||||
return maintained(required).includes(CHECK_CONTEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pending words a head's merge check is announced with (novox/hq issue 446): where the check is, and since when,
|
||||
* in the time the operator reads. They stood as "the mesh is mapping this head onto its module graph" for the whole
|
||||
@@ -257,8 +285,14 @@ export function composedGate(c: Checked): boolean {
|
||||
return c.gate?.verdict === "composed";
|
||||
}
|
||||
|
||||
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */
|
||||
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] {
|
||||
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said; of them, only those the
|
||||
* base requires (`required`, the base's rule: maintained). */
|
||||
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string, required?: string[]): CommitStatus[] {
|
||||
const kept = maintained(required);
|
||||
return allStatusesFor(c, facts, gateNow).filter((s) => kept.includes(s.context));
|
||||
}
|
||||
|
||||
function allStatusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] {
|
||||
if (composedGate(c)) {
|
||||
// A member whose gate the group's composed check judges (ADR 0403 §1): only its repository's check is its own to
|
||||
// set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it.
|
||||
@@ -292,11 +326,13 @@ function judgedFirst(c: Checked, status: CommitStatus): CommitStatus {
|
||||
* change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the
|
||||
* repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing,
|
||||
* is said by its statuses alone, not by a comment on every push. */
|
||||
export function commentFor(c: Checked, facts: RepoCheckFacts = {}): string | null {
|
||||
export function commentFor(c: Checked, facts: RepoCheckFacts = {}, required?: string[]): string | null {
|
||||
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
||||
// A base that no longer requires the gate (issue 1301) is told nothing of it: its verdict is the repository's own check.
|
||||
const withGate = writesGate(required);
|
||||
const repo = c["repo-check"];
|
||||
const wrong = repoWrong(repo, facts);
|
||||
if (gate.verdict === "pass" && !wrong && !builds(c.plan)) return null;
|
||||
if ((!withGate || gate.verdict === "pass") && !wrong && !builds(c.plan)) return null;
|
||||
const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""];
|
||||
if (c.trunk) {
|
||||
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and what was read beside it.
|
||||
@@ -312,9 +348,11 @@ export function commentFor(c: Checked, facts: RepoCheckFacts = {}): string | nul
|
||||
} else if (c.siblings_unknown) {
|
||||
lines.splice(c.trunk ? 2 : 1, 0, `Siblings could not be learned: ${c.siblings_unknown}`);
|
||||
}
|
||||
lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` +
|
||||
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
|
||||
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
|
||||
if (withGate) {
|
||||
lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` +
|
||||
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
|
||||
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
|
||||
}
|
||||
if (repo) {
|
||||
const st = repoStatus(repo, facts);
|
||||
lines.push(`- \`${REPO_CHECK_CONTEXT}\`: ` + (repo.verdict === "warning" && facts.defined === false
|
||||
|
||||
@@ -61,3 +61,38 @@ test("one view per pull request, found by its marker; only the mesh's statuses",
|
||||
assert.equal(deliveryStatus("mesh/repo-check", "pending", "checking again: main moved on").context, "mesh/repo-check");
|
||||
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
|
||||
});
|
||||
|
||||
// Issue 1301-S4 (ADR 0447 §1; the lead's choice A): 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
|
||||
// alone lists in its manifest's `invokes`, so only its account may call it.
|
||||
test("mesh/check is refused by the status tool and written by the check's own tool alone", async () => {
|
||||
const { commitStatusTool, checkStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
|
||||
assert.equal(CHECK_STATUS, "mesh/check");
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const sent: string[] = [];
|
||||
const setCommitStatus = async (o: string, r: string, s: string, st: any) =>
|
||||
void sent.push(`${o}/${r}@${s.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"); } };
|
||||
for (const state of ["pending", "success", "failure", "error", "warning"]) {
|
||||
assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/);
|
||||
await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha,
|
||||
context: "mesh/check", state, description: "by hand" }), /gitea_check_status/);
|
||||
}
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -322,3 +322,13 @@ test("a repository as the mesh clones it is told from another", async () => {
|
||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false);
|
||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false);
|
||||
});
|
||||
|
||||
// Issue 1301: a build machine's start on a head that holds no mesh/merge-gate (its base requires none, so none was written)
|
||||
// says nothing: no "building on" status appears on a main that does not require the gate.
|
||||
test("a start on a head with no gate on the forge writes nothing", async () => {
|
||||
const { GateWriter } = await import("../gate.ts");
|
||||
const forge = aForge();
|
||||
const gates = new GateWriter(forge, announcedAt);
|
||||
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false);
|
||||
assert.equal(forge.kept["mesh/merge-gate"], undefined);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { GiteaClient } from "../dist/client.js";
|
||||
import { ConfiguredToken } from "../dist/token.js";
|
||||
import { getGiteaTools } from "../dist/tools/index.js";
|
||||
import { mergeTool } from "../dist/merge.js";
|
||||
import { commitStatusTool } from "../dist/delivery.js";
|
||||
import { checkStatusTool, commitStatusTool } from "../dist/delivery.js";
|
||||
import { GateWriter } from "../dist/gate.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({
|
||||
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer,
|
||||
}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
||||
}), 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 }); },
|
||||
now: () => new Date(2026, 9, 12, 15, 1) })];
|
||||
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
||||
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
|
||||
@@ -568,6 +568,10 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
|
||||
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
|
||||
["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_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_statuses", { ...repo, sha: ".." }],
|
||||
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
|
||||
["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }],
|
||||
@@ -704,3 +708,53 @@ test("a group of two merges in one pass: the sibling's merge of the composed hea
|
||||
assert.equal(done.merged, true);
|
||||
assert.equal(state.emitted.length, 0, "no check was asked");
|
||||
});
|
||||
|
||||
// **Issue 1301 (ADR 0447 §1, §4; ADR 0503 §3, §5): the merge tool judges on the status the base requires.** A main that
|
||||
// requires mesh/check is judged by the checker's line ("pass <head12> on <trunk12> …"): it merges on a pass that names the
|
||||
// trunk's head, and refuses one that names an older trunk, naming both, writing nothing and asking nothing — a re-check on
|
||||
// a trunk's move is the checker's. No mesh/merge-gate is read or written there.
|
||||
test("gitea_merge_pull_request judges a main requiring mesh/check on mesh/check alone, and asks nothing of it", async () => {
|
||||
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
||||
state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/check"] }];
|
||||
const line = (trunk: string, s = "success", words = "pass") =>
|
||||
[{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }];
|
||||
const before = state.emitted.length;
|
||||
|
||||
state.statuses = line(MOVED);
|
||||
await assert.rejects(merge(), (err: Error) => {
|
||||
assert.match(err.message, /mesh\/check/);
|
||||
assert.match(err.message, new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`));
|
||||
assert.match(err.message, /checker checks it again/i);
|
||||
assert.match(err.message, /Nothing was merged/);
|
||||
return true;
|
||||
});
|
||||
state.statuses = line(TRUNK, "pending", "checking");
|
||||
await assert.rejects(merge(), /mesh\/check is pending/);
|
||||
state.statuses = judgedAt(TRUNK); // the old two, current: not what this main requires
|
||||
await assert.rejects(merge(), /mesh\/check/);
|
||||
assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's");
|
||||
assert.equal(state.emitted.length, before, "no check is asked");
|
||||
assert.equal(state.merged, false);
|
||||
|
||||
state.statuses = line(TRUNK);
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
// hq's main requires mesh/repo-check alone (ADR 0503 §5): judged on its words, and an out-of-date one is asked again on
|
||||
// mesh/repo-check alone; no mesh/merge-gate is written on it.
|
||||
test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on it, and writes no mesh/merge-gate", async () => {
|
||||
const { keepSiblings, siblingsFile } = await import("../dist/siblings.js");
|
||||
keepSiblings(siblingsFile(stateDir), {}); // no sibling record left by the tests before
|
||||
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
||||
state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/repo-check"] }];
|
||||
state.statuses = [judgedAt(MOVED)[1]];
|
||||
const before = state.emitted.length;
|
||||
await assert.rejects(merge(), /judged against trunk/);
|
||||
assert.deepEqual(state.sent.filter((s) => s.path.includes("/statuses/")).map((s) => `${s.body.context} ${s.body.state}`),
|
||||
["mesh/repo-check pending"]);
|
||||
assert.equal(state.emitted.length, before + 1, "its check is asked once");
|
||||
await assert.rejects(merge(), /already asked/);
|
||||
state.sent = [];
|
||||
state.statuses = [judgedAt(TRUNK)[1]];
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
@@ -192,3 +192,27 @@ test("a member's own verdict with a composed gate layer leaves the gate to the c
|
||||
// A verdict with an ordinary gate is as before.
|
||||
assert.equal(composedGate({ ...c, gate: { verdict: "pass", summary: "x" } }), false);
|
||||
});
|
||||
|
||||
// Issue 1301 (ADR 0503 §3, §5): gitea writes the controller's statuses only where the base still requires them. hq's main
|
||||
// requires mesh/repo-check alone: its verdict sets mesh/repo-check and no mesh/merge-gate, and its comment names no gate.
|
||||
test("a base's required statuses decide which of the controller's statuses are written", async () => {
|
||||
const { maintained, writesGate, statusesFor, commentFor, CHECK_STATUS } = await import("../pulls.ts");
|
||||
assert.equal(CHECK_STATUS, "mesh/check");
|
||||
assert.deepEqual(maintained(undefined), ["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before");
|
||||
assert.deepEqual(maintained(["mesh/repo-check"]), ["mesh/repo-check"]);
|
||||
assert.deepEqual(maintained(["mesh/check", "other/x"]), []);
|
||||
assert.deepEqual(maintained([]), []);
|
||||
assert.equal(writesGate(undefined), true);
|
||||
assert.equal(writesGate(["mesh/repo-check"]), false);
|
||||
assert.equal(writesGate(["mesh/check"]), false);
|
||||
assert.equal(writesGate(["mesh/merge-gate", "mesh/repo-check"]), true);
|
||||
const c = { owner: "novox", repo: "hq", number: 5, commit: "a".repeat(40), id: "x", verdict: "warning", summary: "no script",
|
||||
trunk: "b".repeat(12), "repo-check": { verdict: "pass", summary: "ok" } } as any;
|
||||
const hq = statusesFor(c, {}, undefined, ["mesh/repo-check"]);
|
||||
assert.deepEqual(hq.map((s: any) => s.context), ["mesh/repo-check"]);
|
||||
assert.match(hq[0].description, /merged into trunk bbbbbbbbbbbb/);
|
||||
assert.deepEqual(statusesFor(c).map((s: any) => s.context), ["mesh/merge-gate", "mesh/repo-check"], "unread: as before");
|
||||
assert.deepEqual(statusesFor(c, {}, undefined, ["mesh/check"]), []);
|
||||
const comment = commentFor(c, {}, ["mesh/repo-check"]);
|
||||
assert.ok(comment === null || !comment.includes("mesh/merge-gate"), comment ?? "");
|
||||
});
|
||||
|
||||
@@ -199,3 +199,21 @@ test("a release branch's move puts every open pull request into it out of date,
|
||||
assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success",
|
||||
description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false);
|
||||
});
|
||||
|
||||
// Issue 1301: a trunk's move puts out of date only the controller's statuses the base still requires; on hq that is
|
||||
// mesh/repo-check alone, judged by its own words; on a main requiring mesh/check, none (the checker's, ADR 0447 §4).
|
||||
test("a trunk's move writes only the statuses the base still requires", async () => {
|
||||
const { outOfDateStatuses, trunkNamed } = await import("../trunk.ts");
|
||||
const old = "1".repeat(40), now = "2".repeat(40);
|
||||
const repo = { state: "success", description: `head ${"a".repeat(12)} merged into trunk ${old.slice(0, 12)} — pass: y` };
|
||||
const gate = { state: "success", description: `head ${"a".repeat(12)} merged into trunk ${old.slice(0, 12)} — pass: x` };
|
||||
assert.deepEqual(outOfDateStatuses({ "mesh/repo-check": repo }, now, ["mesh/repo-check"]).map((s: any) => s.context), ["mesh/repo-check"]);
|
||||
assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now, ["mesh/repo-check"]).map((s: any) => s.context),
|
||||
["mesh/repo-check"]);
|
||||
assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now, ["mesh/check"]), []);
|
||||
assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now).map((s: any) => s.context),
|
||||
["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before");
|
||||
// The checker's line names its trunk as "<head12> on <trunk12>".
|
||||
assert.equal(trunkNamed(`pass ${"a".repeat(12)} on ${old.slice(0, 12)} | unit 3 run`), old.slice(0, 12));
|
||||
assert.equal(trunkNamed("building on shanks since 10:00"), undefined);
|
||||
});
|
||||
|
||||
@@ -350,3 +350,14 @@ test("a pull request's edit event carries its description, and no other pull req
|
||||
assert.equal(eventOf("pull_request", { action, pull_request: pull, repository }).body.body, undefined, action);
|
||||
}
|
||||
});
|
||||
|
||||
// Issue 1302: a status event carries the status's words, so the delivery gate reads mesh/check's line (the head and the trunk
|
||||
// it judged) from the event itself.
|
||||
test("a status event carries its context, state and words", async () => {
|
||||
const { eventOf } = await import("../webhook.ts");
|
||||
const e = eventOf("status", { sha: "a".repeat(40), context: "mesh/check", state: "success",
|
||||
description: "pass aaaaaaaaaaaa on bbbbbbbbbbbb | unit 3 run, 0 kept", repository: { name: "x", owner: { login: "novox" } } });
|
||||
assert.equal(e.name, "forge.status");
|
||||
assert.equal((e.body as any).context, "mesh/check");
|
||||
assert.equal((e.body as any).description, "pass aaaaaaaaaaaa on bbbbbbbbbbbb | unit 3 run, 0 kept");
|
||||
});
|
||||
|
||||
@@ -395,7 +395,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
// ---- Branch protection (novox/hq ADR 0237) ----
|
||||
{
|
||||
name: "gitea_branch_protection_get",
|
||||
description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/merge-gate), approvals, and whether an administrator may merge past them.",
|
||||
description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/check), approvals, and whether an administrator may merge past them.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
@@ -413,12 +413,12 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_branch_protection_set",
|
||||
description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
|
||||
description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's checker sets `mesh/check` (ADR 0447), and the repositories program requires it on every main. Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch to protect, e.g. main" },
|
||||
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/merge-gate,mesh/repo-check; empty requires none" },
|
||||
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/check; empty requires none" },
|
||||
push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" },
|
||||
block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" },
|
||||
},
|
||||
@@ -478,7 +478,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_statuses",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
|
||||
+24
-12
@@ -12,12 +12,17 @@
|
||||
// the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a
|
||||
// new head makes — and says how long the last check of that repository took. It never merges by itself after.
|
||||
//
|
||||
// Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged
|
||||
// ("pass <head12> on <trunk12>"), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict
|
||||
// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (hq's
|
||||
// mesh/repo-check, pulls.ts maintained).
|
||||
//
|
||||
// Pure functions and two small files of the module's own state, so this is tested without a forge.
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, WAITS_FOR_COMPOSED } from "./pulls.js";
|
||||
import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js";
|
||||
|
||||
/** A commit as a verdict names it: its first twelve characters. */
|
||||
export function twelve(sha: string | undefined): string {
|
||||
@@ -40,7 +45,9 @@ export function judgedWords(head: string, trunk: string): string {
|
||||
* verdict put out of date, the one it had judged ("judged against trunk …"). Undefined when it names none, as a
|
||||
* verdict of a build seat from before ADR 0323 does. */
|
||||
export function trunkNamed(description: string | undefined): string | undefined {
|
||||
const m = /(?:merged into|judged against) trunk ([0-9a-f]{7,64})\b/i.exec(description ?? "");
|
||||
const m = /(?:merged into|judged against) trunk ([0-9a-f]{7,64})\b/i.exec(description ?? "")
|
||||
// The checker's mesh/check line (ADR 0447 §3): "<head12> on <trunk12>".
|
||||
?? /\b[0-9a-f]{12} on ([0-9a-f]{12})\b/i.exec(description ?? "");
|
||||
return m ? m[1].toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
@@ -70,15 +77,15 @@ export function waitsForComposed(gate: HeadCheck | undefined): boolean {
|
||||
* gate, and the repository's own check when the head has one — a pending repo-check a verdict will not set would
|
||||
* stand for ever (novox/hq issue 305's rule).
|
||||
*/
|
||||
export function outOfDateStatuses(checks: Record<string, HeadCheck>, trunkHead: string): CommitStatus[] {
|
||||
const gate = checks[CHECK_CONTEXT];
|
||||
if (!gate || gate.state === "pending") return [];
|
||||
const judged = trunkNamed(gate.description);
|
||||
export function outOfDateStatuses(checks: Record<string, HeadCheck>, trunkHead: string, required?: string[]): CommitStatus[] {
|
||||
// Only the statuses the base still requires (issue 1301): the first of them the head holds judges.
|
||||
const kept = maintained(required).filter((c) => checks[c]);
|
||||
const judge = kept.length ? checks[kept[0]] : undefined;
|
||||
if (!judge || judge.state === "pending") return [];
|
||||
const judged = trunkNamed(judge.description);
|
||||
if (judged && sameCommit(judged, trunkHead)) return [];
|
||||
const description = outOfDateWords(judged, trunkHead);
|
||||
const out: CommitStatus[] = [{ state: "pending", context: CHECK_CONTEXT, description }];
|
||||
if (checks[REPO_CHECK_CONTEXT]) out.push({ state: "pending", context: REPO_CHECK_CONTEXT, description });
|
||||
return out;
|
||||
return kept.map((context) => ({ state: "pending" as const, context, description }));
|
||||
}
|
||||
|
||||
/** Where the merge tool stands with a head's verdict and the trunk's head (ADR 0323 §5). */
|
||||
@@ -91,8 +98,8 @@ export interface VerdictAtTrunk {
|
||||
judged?: string;
|
||||
}
|
||||
|
||||
export function verdictAtTrunk(checks: Record<string, HeadCheck>, trunkHead: string): VerdictAtTrunk {
|
||||
const gate = checks[CHECK_CONTEXT];
|
||||
export function verdictAtTrunk(checks: Record<string, HeadCheck>, trunkHead: string, context = CHECK_CONTEXT): VerdictAtTrunk {
|
||||
const gate = checks[context];
|
||||
const judged = trunkNamed(gate?.description);
|
||||
if (checkOnItsWay(gate)) return { current: false, onItsWay: true, judged };
|
||||
return { current: !!gate && gate.state !== "pending" && !!judged && sameCommit(judged, trunkHead), onItsWay: false, judged };
|
||||
@@ -119,6 +126,9 @@ export function lateVerdictComment(c: { commit: string; trunk?: string; verdict:
|
||||
export interface TrunkForge {
|
||||
listOpenPulls(owner: string, repo: string): Promise<GiteaPull[]>;
|
||||
branchHead(owner: string, repo: string, branch: string): Promise<string>;
|
||||
/** The rule the forge applies to a branch: which of the controller's statuses are kept there (issue 1301). Not given,
|
||||
* or not read: both, as before. */
|
||||
appliedProtection?(owner: string, repo: string, branch: string): Promise<{ enable_status_check?: boolean; status_check_contexts?: string[] } | null>;
|
||||
}
|
||||
|
||||
/** What it writes with: the merge check's one writer (gate.ts), which decides in the head's own turn. */
|
||||
@@ -147,11 +157,13 @@ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, tru
|
||||
if (before && sameCommit(before, head)) continue;
|
||||
if (before) {
|
||||
let put = 0;
|
||||
const required = forge.appliedProtection
|
||||
? requiredOf(await forge.appliedProtection(owner, name, base).catch(() => undefined)) : undefined;
|
||||
for (const pull of open.filter((p) => p.base === base)) {
|
||||
// The trunk is read again in the head's own turn: a verdict judged against a head newer than the pass's first
|
||||
// read — the trunk moved again meanwhile, and a check answered — is not put out of date.
|
||||
const written = await gates.outOfDate(owner, name, String(pull.head_sha),
|
||||
async (checks) => outOfDateStatuses(checks, await forge.branchHead(owner, name, base)), pull.html_url || undefined);
|
||||
async (checks) => outOfDateStatuses(checks, await forge.branchHead(owner, name, base), required), pull.html_url || undefined);
|
||||
if (written.length) put++;
|
||||
}
|
||||
console.log(`[gitea] ${owner}/${name}'s ${base} moved from ${twelve(before)} to ${twelve(head)}: ${put} open pull ` +
|
||||
|
||||
@@ -127,7 +127,9 @@ function forgeEventOf(said: string, payload: any): ForgeEvent {
|
||||
return { name: kind, body: { ...base, ref: str(payload.ref), ref_type: str(payload.ref_type), sha: str(payload.sha) } };
|
||||
}
|
||||
if (kind === "status") {
|
||||
return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state) } };
|
||||
// Its words too (issue 1302): mesh/check's line names the head and the trunk it judged, which the delivery gate reads.
|
||||
return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state),
|
||||
description: str(payload.description) } };
|
||||
}
|
||||
const name = family === "other" ? `other.${kind.replace(/[^a-z0-9_-]/gi, "_") || "unknown"}` :
|
||||
["fork"].includes(family) ? family : `${family}.${action ?? "changed"}`;
|
||||
|
||||
@@ -257,8 +257,7 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
|
||||
for _, never := range []string{"/home/", "jochen", "api_key", ".hal/", "greenclip daemon"} {
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function hasField(schema: readonly SchemaField[] | null | undefined, name
|
||||
// **Nothing loses its connection without someone seeing it.** Before Home Assistant is touched the
|
||||
// broker itself is asked whether it takes the delivered login (the provisioner creates it within
|
||||
// seconds of the grant): if not, nothing is written and the step fails saying why, and Home
|
||||
// Assistant keeps the login it has — the carried `luffy` on node-a, which mosquitto keeps. If Home
|
||||
// Assistant keeps the login it has — the carried `luffy` on ace, which mosquitto keeps. If Home
|
||||
// Assistant's own connection test refuses the new settings, the flow saves nothing, and the step
|
||||
// fails with Home Assistant's reason. A login that may not subscribe to the discovery topics is said
|
||||
// as a warning: discovery would find nothing.
|
||||
@@ -299,8 +299,8 @@ export function describe(r: { type: string; reason?: string; errors?: Record<str
|
||||
// is the bound one and which is loaded: already as the mesh says. The key needs no digest here:
|
||||
// a Servarr app has one key, so an entry loaded against the app holds the key the app took.
|
||||
// 5. A working entry at a different URL that reaches **the same app** — the same process, by the
|
||||
// app's own status (start time, data folder, version) — is left as it is and said: node-a's entries
|
||||
// say `127.0.0.1:<port>` and the binding says `node-a.internal:<port>`, one Sonarr either way.
|
||||
// app's own status (start time, data folder, version) — is left as it is and said: ace's entries
|
||||
// say `127.0.0.1:<port>` and the binding says `ace.internal:<port>`, one Sonarr either way.
|
||||
// 6. Anything else is refused, loudly, with what the operator can do; nothing is removed.
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// minted value before the operator accepts the app's) is never written.
|
||||
//
|
||||
// Home Assistant and the apps are fakes answering as the real ones do (flow shapes checked against
|
||||
// ghcr.io/home-assistant/home-assistant 2026.9.3, the build node-a runs).
|
||||
// ghcr.io/home-assistant/home-assistant 2026.9.3, the build ace runs).
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -21,7 +21,7 @@ const PWD_NOT_CHANGED = "__**password_not_changed**__";
|
||||
const MINTED = "mesh-minted-password";
|
||||
|
||||
function mqttBinding(): Binding {
|
||||
return { provision: "mqtt-topic", from: "node-a", at: "node-a.internal", as: "mesh_node-a_hass", serves: { scheme: "mqtt", port: 1883 } };
|
||||
return { provision: "mqtt-topic", from: "ace", at: "ace.internal", as: "mesh_ace_hass", serves: { scheme: "mqtt", port: 1883 } };
|
||||
}
|
||||
|
||||
/** The MQTT reconfigure form as Home Assistant serializes it, pre-filled from an entry. */
|
||||
@@ -138,29 +138,29 @@ function memoryMarks(): Marks & { store: Map<string, string> } {
|
||||
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
|
||||
}
|
||||
|
||||
const takes = (suback = 0): Probe => async (_h, _p, user, pass) => ({ connack: user === "mesh_node-a_hass" && pass === MINTED ? 0 : 5, suback });
|
||||
const takes = (suback = 0): Probe => async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_hass" && pass === MINTED ? 0 : 5, suback });
|
||||
|
||||
const nodeAMqttEntry = () => ({
|
||||
const aceMqttEntry = () => ({
|
||||
entry_id: "7d1e", domain: "mqtt", state: "loaded",
|
||||
data: { broker: "127.0.0.1", port: 1883, protocol: "5", username: "luffy", password: "luffys-password" },
|
||||
});
|
||||
|
||||
test("mqtt: the broker is asked first; a login it does not take is never written", async () => {
|
||||
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] } });
|
||||
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
|
||||
const out = await reconcileMqtt({ hass: f.hass, probe: async () => ({ connack: 5 }), marks: memoryMarks() }, mqttBinding(), MINTED);
|
||||
assert.equal(out.result, "refused");
|
||||
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_node-a_hass/);
|
||||
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_ace_hass/);
|
||||
assert.deepEqual(f.calls, []); // Home Assistant not even asked
|
||||
assert.equal(f.entries.mqtt[0].data.username, "luffy");
|
||||
});
|
||||
|
||||
test("mqtt: node-a's entry (127.0.0.1, luffy) is moved to the bound broker and login, every other setting kept", async () => {
|
||||
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] } });
|
||||
test("mqtt: ace's entry (127.0.0.1, luffy) is moved to the bound broker and login, every other setting kept", async () => {
|
||||
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
|
||||
const marks = memoryMarks();
|
||||
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), `${MINTED}\n`);
|
||||
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["broker", "username", "password"] });
|
||||
assert.deepEqual(f.entries.mqtt[0].data, {
|
||||
broker: "node-a.internal", port: 1883, protocol: "5", username: "mesh_node-a_hass", password: MINTED,
|
||||
broker: "ace.internal", port: 1883, protocol: "5", username: "mesh_ace_hass", password: MINTED,
|
||||
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
|
||||
});
|
||||
assert.ok(marks.store.get("mqtt"));
|
||||
@@ -175,7 +175,7 @@ test("mqtt: node-a's entry (127.0.0.1, luffy) is moved to the bound broker and l
|
||||
});
|
||||
|
||||
test("mqtt: a new password alone is written (the digest tells)", async () => {
|
||||
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] } });
|
||||
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
|
||||
const marks = memoryMarks();
|
||||
await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
|
||||
const rotated: Probe = async () => ({ connack: 0, suback: 0 });
|
||||
@@ -185,7 +185,7 @@ test("mqtt: a new password alone is written (the digest tells)", async () => {
|
||||
});
|
||||
|
||||
test("mqtt: Home Assistant's own connection test refusing saves nothing and fails loudly", async () => {
|
||||
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] }, accepts: () => false });
|
||||
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] }, accepts: () => false });
|
||||
const marks = memoryMarks();
|
||||
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
|
||||
assert.equal(out.result, "refused");
|
||||
@@ -199,11 +199,11 @@ test("mqtt: a fresh Home Assistant gets an entry; a grant without the discovery
|
||||
const out = await reconcileMqtt({ hass: f.hass, probe: takes(0x80), marks: memoryMarks() }, mqttBinding(), MINTED);
|
||||
assert.equal(out.result, "written");
|
||||
assert.match((out as { note?: string }).note ?? "", /may not subscribe to homeassistant\/#/);
|
||||
assert.equal(f.entries.mqtt[0].data.broker, "node-a.internal");
|
||||
assert.equal(f.entries.mqtt[0].data.broker, "ace.internal");
|
||||
});
|
||||
|
||||
test("mqtt: two entries, or a binding without a port, are refused rather than guessed", async () => {
|
||||
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry(), { ...nodeAMqttEntry(), entry_id: "other" }] } });
|
||||
const f = fakeHass({ entries: { mqtt: [aceMqttEntry(), { ...aceMqttEntry(), entry_id: "other" }] } });
|
||||
assert.equal((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, mqttBinding(), MINTED)).result, "refused");
|
||||
const noPort = { ...mqttBinding(), serves: {} };
|
||||
assert.match(((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, noPort, MINTED)) as { problem: string }).problem, /no usable port/);
|
||||
@@ -214,9 +214,9 @@ test("mqtt: two entries, or a binding without a port, are refused rather than gu
|
||||
const SONARR = APPS.find((a) => a.domain === "sonarr") as ServarrApp;
|
||||
const RADARR = APPS.find((a) => a.domain === "radarr") as ServarrApp;
|
||||
const KEY = "the-apps-own-key";
|
||||
const servarrBinding = (port: number, at = "node-a.internal"): Binding => ({ provision: "sonarr-api", from: "node-a", at, as: "mesh_node-a_hass", serves: { scheme: "http", port, "url-base": "" } });
|
||||
const servarrBinding = (port: number, at = "ace.internal"): Binding => ({ provision: "sonarr-api", from: "ace", at, as: "mesh_ace_hass", serves: { scheme: "http", port, "url-base": "" } });
|
||||
|
||||
/** One running Sonarr, answering on several addresses (127.0.0.1 and node-a.internal are one host). */
|
||||
/** One running Sonarr, answering on several addresses (127.0.0.1 and ace.internal are one host). */
|
||||
function apps(instances: Record<string, { startTime: string }>): Http & { asked: string[] } {
|
||||
const asked: string[] = [];
|
||||
return {
|
||||
@@ -231,18 +231,18 @@ function apps(instances: Record<string, { startTime: string }>): Http & { asked:
|
||||
},
|
||||
};
|
||||
}
|
||||
const oneSonarr = () => apps({ "node-a.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "t1" } });
|
||||
const oneSonarr = () => apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "t1" } });
|
||||
const sonarrEntry = (state = "loaded") => ({ entry_id: "5a1d", domain: "sonarr", state, data: { url: "http://127.0.0.1:8989", api_key: KEY } });
|
||||
|
||||
test("servarr: the mesh's minted key is never written; the remedy names the accept", async () => {
|
||||
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] } });
|
||||
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), "minted-by-the-mesh");
|
||||
assert.equal(out.result, "refused");
|
||||
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider node-a/);
|
||||
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider ace/);
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("servarr: node-a's entry at 127.0.0.1 reaches the same Sonarr the mesh bound at node-a.internal — left, and said", async () => {
|
||||
test("servarr: ace's entry at 127.0.0.1 reaches the same Sonarr the mesh bound at ace.internal — left, and said", async () => {
|
||||
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
|
||||
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
|
||||
assert.equal(out.result, "equivalent");
|
||||
@@ -250,13 +250,13 @@ test("servarr: node-a's entry at 127.0.0.1 reaches the same Sonarr the mesh boun
|
||||
});
|
||||
|
||||
test("servarr: an entry already at the bound URL is unchanged", async () => {
|
||||
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://node-a.internal:8989" }] });
|
||||
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://ace.internal:8989" }] });
|
||||
assert.deepEqual(await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY), { what: "sonarr", result: "unchanged" });
|
||||
});
|
||||
|
||||
test("servarr: a working entry that reaches a different app is refused, and nothing is removed", async () => {
|
||||
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
|
||||
const two = apps({ "node-a.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
|
||||
const two = apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
|
||||
const out = await reconcileApp({ hass: f.hass, http: two }, SONARR, servarrBinding(8989), KEY);
|
||||
assert.equal(out.result, "refused");
|
||||
assert.match((out as { problem: string }).problem, /never does/);
|
||||
@@ -267,7 +267,7 @@ test("servarr: no entry — one is made at the bound URL through the user flow",
|
||||
const f = fakeHass({ entries: {} });
|
||||
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
|
||||
assert.deepEqual(out, { what: "sonarr", result: "written", fields: ["entry"] });
|
||||
assert.deepEqual(f.submitted[0], { url: "http://node-a.internal:8989", api_key: KEY, more_options: { verify_ssl: false } });
|
||||
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:8989", api_key: KEY, more_options: { verify_ssl: false } });
|
||||
});
|
||||
|
||||
test("servarr: a reauth Home Assistant started is finished with the bound URL and key", async () => {
|
||||
@@ -276,10 +276,10 @@ test("servarr: a reauth Home Assistant started is finished with the bound URL an
|
||||
entries: { radarr: [entry] },
|
||||
reauth: [{ flow_id: "r1", handler: "radarr", step_id: "reauth_confirm", context: { source: "reauth", entry_id: "1955" } }],
|
||||
});
|
||||
const radarr = apps({ "node-a.internal:7878": { startTime: "t" } });
|
||||
const radarr = apps({ "ace.internal:7878": { startTime: "t" } });
|
||||
const out = await reconcileApp({ hass: f.hass, http: radarr }, RADARR, { ...servarrBinding(7878), provision: "radarr-api" }, KEY);
|
||||
assert.deepEqual(out, { what: "radarr", result: "written", fields: ["api_key", "url"] });
|
||||
assert.deepEqual(f.submitted[0], { url: "http://node-a.internal:7878", api_key: KEY, verify_ssl: false });
|
||||
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:7878", api_key: KEY, verify_ssl: false });
|
||||
});
|
||||
|
||||
test("form values: suggested first, then default, sections nested", () => {
|
||||
@@ -287,7 +287,7 @@ test("form values: suggested first, then default, sections nested", () => {
|
||||
broker: "b", port: 1, protocol: "5", username: "u", password: PWD_NOT_CHANGED,
|
||||
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
|
||||
});
|
||||
assert.ok(sameUrl("http://node-a.internal:8989/", "http://node-a.internal:8989"));
|
||||
assert.ok(sameUrl("http://NODE-A.internal", "http://node-a.internal:80"));
|
||||
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://node-a.internal:8989"));
|
||||
assert.ok(sameUrl("http://ace.internal:8989/", "http://ace.internal:8989"));
|
||||
assert.ok(sameUrl("http://ACE.internal", "http://ace.internal:80"));
|
||||
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://ace.internal:8989"));
|
||||
});
|
||||
|
||||
@@ -261,8 +261,7 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
|
||||
for _, never := range []string{"/home/", "jochen", "api_key", ".hal/", "greenclip daemon"} {
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
|
||||
@@ -108,13 +108,13 @@ const grants = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMI
|
||||
|
||||
const PW = "mesh-minted-password-of-forty-characters";
|
||||
|
||||
/** Grafana on node-a, as the mesh hands it to the provisioner. */
|
||||
/** Grafana on ace, as the mesh hands it to the provisioner. */
|
||||
function grafana(password = PW, values: Record<string, unknown> = { access: "read" }) {
|
||||
return { as: "mesh_node-a_grafana", password, consumer: "node-a", values };
|
||||
return { as: "mesh_ace_grafana", password, consumer: "ace", values };
|
||||
}
|
||||
/** Node-RED on node-a: writes one bucket. */
|
||||
/** Node-RED on ace: writes one bucket. */
|
||||
function nodered(password = PW, values: Record<string, unknown> = { access: "write", buckets: ["zurag"] }) {
|
||||
return { as: "mesh_node-a_nodered", password, consumer: "node-a", values };
|
||||
return { as: "mesh_ace_nodered", password, consumer: "ace", values };
|
||||
}
|
||||
|
||||
function only(token: string): Rec {
|
||||
@@ -144,7 +144,7 @@ test("what a contribution may ask for, and what is refused", () => {
|
||||
|
||||
test("a reader is given one authorization, reading every bucket of the org, under the mesh's password", async () => {
|
||||
assert.equal(await grants.ensure(grafana()), "created");
|
||||
const a = only("mesh_node-a_grafana");
|
||||
const a = only("mesh_ace_grafana");
|
||||
assert.equal(a.orgID, "org1");
|
||||
assert.equal(a.status, "active");
|
||||
assert.ok(a.description.startsWith(MARK));
|
||||
@@ -158,8 +158,8 @@ test("a writer is allowed its own buckets only, and a missing one is made — ne
|
||||
const made = buckets.find((b) => b.name === "printer");
|
||||
assert.ok(made, "the missing bucket was created");
|
||||
assert.deepEqual(made!.retentionRules, [], "kept for ever: retention is the operator's choice");
|
||||
assert.deepEqual(perms(only("mesh_node-a_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
|
||||
assert.equal(await grants.remove("mesh_node-a_nodered"), "removed");
|
||||
assert.deepEqual(perms(only("mesh_ace_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
|
||||
assert.equal(await grants.remove("mesh_ace_nodered"), "removed");
|
||||
assert.equal(buckets.length, 2, "withdrawing the consumer leaves every bucket and its data");
|
||||
});
|
||||
|
||||
@@ -168,30 +168,30 @@ test("applying the same grant again writes nothing", async () => {
|
||||
calls = [];
|
||||
assert.equal(await grants.ensure(grafana()), "unchanged");
|
||||
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
|
||||
only("mesh_node-a_grafana");
|
||||
only("mesh_ace_grafana");
|
||||
});
|
||||
|
||||
test("a rotated password is set in place; a changed access remakes only the mesh's own", async () => {
|
||||
await grants.ensure(nodered());
|
||||
const id = only("mesh_node-a_nodered").id;
|
||||
const id = only("mesh_ace_nodered").id;
|
||||
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), false);
|
||||
assert.equal(await grants.ensure(nodered("rotated-password-0123456789")), "updated");
|
||||
assert.equal(only("mesh_node-a_nodered").id, id, "updated, not replaced");
|
||||
assert.equal(only("mesh_ace_nodered").id, id, "updated, not replaced");
|
||||
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), true);
|
||||
|
||||
await grants.ensure(nodered(PW, { access: "read-write", buckets: ["zurag"] }));
|
||||
assert.deepEqual(perms(only("mesh_node-a_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
|
||||
assert.deepEqual(perms(only("mesh_ace_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
|
||||
assert.equal(await grants.holds(nodered(PW, { access: "read-write", buckets: ["zurag"] })), true);
|
||||
});
|
||||
|
||||
test("an authorization disabled, re-passworded or deleted behind the mesh's back is not held, and is made whole", async () => {
|
||||
await grants.ensure(grafana());
|
||||
only("mesh_node-a_grafana").status = "inactive";
|
||||
only("mesh_ace_grafana").status = "inactive";
|
||||
assert.equal(await grants.holds(grafana()), false);
|
||||
assert.equal(await grants.ensure(grafana()), "updated");
|
||||
assert.equal(await grants.holds(grafana()), true);
|
||||
|
||||
only("mesh_node-a_grafana").password = "somebody-else-set-this";
|
||||
only("mesh_ace_grafana").password = "somebody-else-set-this";
|
||||
assert.equal(await grants.holds(grafana()), false);
|
||||
await grants.ensure(grafana());
|
||||
assert.equal(await grants.holds(grafana()), true);
|
||||
@@ -211,14 +211,14 @@ test("holds only reads, and a bucket gone missing is not held rather than made",
|
||||
});
|
||||
|
||||
test("an authorization of the same name the mesh did not make is refused, and left exactly as it was", async () => {
|
||||
auths = [{ id: "theirs", token: "mesh_node-a_grafana", orgID: "org1", status: "active", description: "hand-made",
|
||||
auths = [{ id: "theirs", token: "mesh_ace_grafana", orgID: "org1", status: "active", description: "hand-made",
|
||||
permissions: [{ action: "write", resource: { type: "buckets", orgID: "org1" } }], password: "their-password" }];
|
||||
const before = JSON.stringify(auths);
|
||||
await assert.rejects(grants.ensure(grafana()), /did not make/);
|
||||
assert.equal(JSON.stringify(auths), before);
|
||||
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
|
||||
assert.equal(await grants.holds(grafana()), false);
|
||||
assert.equal(await grants.remove("mesh_node-a_grafana"), "not ours");
|
||||
assert.equal(await grants.remove("mesh_ace_grafana"), "not ours");
|
||||
assert.equal(auths.length, 1, "never deleted");
|
||||
});
|
||||
|
||||
@@ -240,7 +240,7 @@ test("an org the instance does not have, or a non-mesh name, makes nothing", asy
|
||||
|
||||
test("a withdrawn consumer's authorization is removed, and an absent one is not an error", async () => {
|
||||
await grants.ensure(grafana());
|
||||
assert.equal(await grants.remove("mesh_node-a_grafana"), "removed");
|
||||
assert.equal(await grants.remove("mesh_ace_grafana"), "removed");
|
||||
assert.equal(auths.length, 0);
|
||||
assert.equal(await grants.remove("mesh_node-a_grafana"), "absent");
|
||||
assert.equal(await grants.remove("mesh_ace_grafana"), "absent");
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# kernel
|
||||
|
||||
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). It holds two things:
|
||||
**transparent huge pages**, set per machine, and **the fallback kernel** (`linux-lts` with its headers) and
|
||||
its place on the boot menu (novox/hq ADR 0508 §9 and §10). The default kernel's package, and microcode, are
|
||||
not built yet.
|
||||
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). Today it holds one:
|
||||
**transparent huge pages**, set per machine. The rest of the row (the kernel package, microcode, boot
|
||||
entries) is not built yet.
|
||||
|
||||
## Why (novox/hq issue 746)
|
||||
|
||||
@@ -36,34 +35,16 @@ under the new mode. A failed rotation is said in the unit's journal and does not
|
||||
rotation is made while the machine is still starting (`systemctl is-system-running` says `initializing`
|
||||
or `starting`): nothing is in huge pages yet, and the persistent journal opens after this unit.
|
||||
|
||||
## The fallback kernel (novox/hq ADR 0508, issue 1841)
|
||||
|
||||
Every machine keeps a second, older kernel to start from, so a kernel upgrade that does not boot is not the
|
||||
end of the machine. The module declares the packages `linux-lts` and `linux-lts-headers` (so every DKMS module is
|
||||
built for the fallback kernel too) after its boot-entries service, so the entry is in place before the kernel is.
|
||||
|
||||
| | what | notes |
|
||||
|---|---|---|
|
||||
| file | `${dir:state}/boot-entries` | the script. It reads from the machine which loader booted it: the EFI variable `LoaderEntrySelected` present means systemd-boot, a `/boot/grub/grub.cfg` without it means GRUB, neither fails the unit loudly |
|
||||
| | systemd-boot | writes `/boot/loader/entries/mesh-linux-lts.conf` from `/proc/cmdline` without `initrd=` and `BOOT_IMAGE=`, with the microcode images present in `/boot` and the fallback initramfs as initrd lines; `loader.conf` and the default entry are never touched |
|
||||
| | GRUB | runs `grub-mkconfig -o /boot/grub/grub.cfg` only when the set of `/boot/vmlinuz-*` differs from the kernels the menu names, with `GRUB_TOP_LEVEL` naming the kernel the machine booted (`BOOT_IMAGE`; else the non-fallback kernel), since GRUB lists kernels in reverse version order and would otherwise make the fallback kernel the default; `/etc/default/grub` still overrides it when it sets one |
|
||||
| | room | while `/boot/vmlinuz-linux-lts` is not there yet, less than 96 MiB free on `/boot` fails the unit and says so (the fallback kernel writes about 72 MB, issue 1836) |
|
||||
| | a kernel-copying hook | a hook in `/etc/pacman.d/hooks` that the mesh did not write, triggered by kernel images and copying to one fixed file name in `/boot`, would put the fallback kernel in the default kernel's place: it is moved into `${dir:state}/taken-over/` and named in the unit's journal. Each kernel is then placed under its own package's name by the distribution's hooks |
|
||||
| file | `/etc/systemd/system/mesh-kernel-boot-entries.service` | oneshot, sandboxed: the file system read-only but for `/boot`, `/etc/pacman.d/hooks` and the module's state; no home, no network |
|
||||
| file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed |
|
||||
| packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook |
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | answers |
|
||||
|---|---|---|
|
||||
| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, and the room free on `/boot` in KiB |
|
||||
| `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) |
|
||||
|
||||
## Tests
|
||||
|
||||
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
|
||||
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh
|
||||
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); and the script as the mesh
|
||||
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
|
||||
rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a
|
||||
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package main
|
||||
|
||||
// kernel_boot: which loader booted the machine, its entries, the kernels in /boot, the entry it booted and
|
||||
// whether the fallback kernel is on the menu (novox/hq ADR 0508 §9, issue 1841). Reads only.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
const loaderSelected = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
|
||||
|
||||
// BootState is what kernel_boot answers.
|
||||
type BootState struct {
|
||||
Loader string `json:"loader"`
|
||||
Entries []string `json:"entries"`
|
||||
Kernels []string `json:"kernels"`
|
||||
Booted string `json:"booted"`
|
||||
FallbackPresent bool `json:"fallback_present"`
|
||||
BootFreeKiB int64 `json:"boot_free_kib"`
|
||||
}
|
||||
|
||||
var grubKernel = regexp.MustCompile(`(?m)^\s*linux\s+(?:/boot)?/(vmlinuz-\S+)`)
|
||||
var grubTitle = regexp.MustCompile(`(?m)^\s*menuentry\s+'([^']*)'`)
|
||||
var bootImage = regexp.MustCompile(`BOOT_IMAGE=(?:/boot)?/?(\S+)`)
|
||||
|
||||
// Boot reads the machine, the loader the way the boot-entries script does: the loader's EFI variable means
|
||||
// systemd-boot, a grub.cfg without it GRUB.
|
||||
func (m *Machine) Boot() (*BootState, error) {
|
||||
b := &BootState{Loader: "none", Entries: []string{}, Kernels: []string{}}
|
||||
files, _ := filepath.Glob(m.path("boot/vmlinuz-*"))
|
||||
for _, f := range files {
|
||||
b.Kernels = append(b.Kernels, filepath.Base(f))
|
||||
}
|
||||
sort.Strings(b.Kernels)
|
||||
hasKernel := func(name string) bool {
|
||||
for _, k := range b.Kernels {
|
||||
if k == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
var s syscall.Statfs_t
|
||||
if err := syscall.Statfs(m.path("boot"), &s); err == nil {
|
||||
b.BootFreeKiB = int64(s.Bavail) * int64(s.Bsize) / 1024
|
||||
}
|
||||
if raw, err := os.ReadFile(m.path(loaderSelected)); err == nil {
|
||||
b.Loader = "systemd-boot"
|
||||
if len(raw) > 4 {
|
||||
var u []uint16
|
||||
for i := 4; i+1 < len(raw); i += 2 {
|
||||
u = append(u, uint16(raw[i])|uint16(raw[i+1])<<8)
|
||||
}
|
||||
b.Booted = strings.TrimRight(string(utf16.Decode(u)), "\x00")
|
||||
}
|
||||
files, _ := filepath.Glob(m.path("boot/loader/entries/*.conf"))
|
||||
for _, f := range files {
|
||||
b.Entries = append(b.Entries, filepath.Base(f))
|
||||
}
|
||||
sort.Strings(b.Entries)
|
||||
if raw, err := os.ReadFile(m.path("boot/loader/entries/mesh-linux-lts.conf")); err == nil {
|
||||
b.FallbackPresent = strings.Contains(string(raw), "/vmlinuz-linux-lts") && hasKernel("vmlinuz-linux-lts")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
if raw, err := os.ReadFile(m.path("boot/grub/grub.cfg")); err == nil {
|
||||
b.Loader = "grub"
|
||||
for _, t := range grubTitle.FindAllStringSubmatch(string(raw), -1) {
|
||||
b.Entries = append(b.Entries, t[1])
|
||||
}
|
||||
for _, k := range grubKernel.FindAllStringSubmatch(string(raw), -1) {
|
||||
if k[1] == "vmlinuz-linux-lts" && hasKernel("vmlinuz-linux-lts") {
|
||||
b.FallbackPresent = true
|
||||
}
|
||||
}
|
||||
if cmd, err := m.read("proc/cmdline"); err == nil {
|
||||
if i := bootImage.FindStringSubmatch(cmd); i != nil {
|
||||
b.Booted = i[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package main
|
||||
|
||||
// kernel_boot over stand-in roots: a systemd-boot machine with and without the fallback entry, a GRUB machine
|
||||
// with and without the fallback kernel in its menu, and a machine with neither (novox/hq ADR 0508 §9, issue 1841).
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const selectedVar = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
|
||||
|
||||
func put(t *testing.T, root, p, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(root, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, full, content, 0o644)
|
||||
}
|
||||
|
||||
func bootOf(t *testing.T, root string) *BootState {
|
||||
t.Helper()
|
||||
got, err := (&Machine{Root: root}).Boot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestKernelBootReadsASystemdBootMachineWithItsFallbackEntry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
|
||||
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
|
||||
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\n")
|
||||
put(t, root, "boot/vmlinuz-linux", "k")
|
||||
put(t, root, "boot/vmlinuz-linux-lts", "k")
|
||||
got := bootOf(t, root)
|
||||
if got.Loader != "systemd-boot" || got.Booted != "arch.conf" || !got.FallbackPresent {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if len(got.Entries) != 2 || got.Entries[0] != "arch.conf" || got.Entries[1] != "mesh-linux-lts.conf" {
|
||||
t.Fatalf("entries: %v", got.Entries)
|
||||
}
|
||||
if len(got.Kernels) != 2 || got.Kernels[0] != "vmlinuz-linux" || got.Kernels[1] != "vmlinuz-linux-lts" {
|
||||
t.Fatalf("kernels: %v", got.Kernels)
|
||||
}
|
||||
if got.BootFreeKiB <= 0 {
|
||||
t.Fatalf("free: %d", got.BootFreeKiB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelBootSaysASystemdBootMachineHasNoFallbackYet(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
|
||||
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
|
||||
put(t, root, "boot/vmlinuz-linux", "k")
|
||||
// The entry file alone is not the fallback: the kernel it names must be in /boot.
|
||||
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "linux /vmlinuz-linux-lts\n")
|
||||
if got := bootOf(t, root); got.FallbackPresent {
|
||||
t.Fatalf("a fallback entry without its kernel: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelBootReadsAGrubMachine(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
put(t, root, "boot/grub/grub.cfg", grubMenu)
|
||||
put(t, root, "boot/vmlinuz-linux", "k")
|
||||
put(t, root, "proc/cmdline", bootedCmdline)
|
||||
got := bootOf(t, root)
|
||||
if got.Loader != "grub" || got.FallbackPresent || got.Booted != "vmlinuz-linux" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if len(got.Entries) != 2 || got.Entries[0] != "Arch Linux" {
|
||||
t.Fatalf("entries: %v", got.Entries)
|
||||
}
|
||||
put(t, root, "boot/vmlinuz-linux-lts", "k")
|
||||
put(t, root, "boot/grub/grub.cfg", grubMenu+"menuentry 'Arch Linux LTS' {\n\tlinux\t/boot/vmlinuz-linux-lts root=x rw\n}\n")
|
||||
if got := bootOf(t, root); !got.FallbackPresent {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelBootSaysWhenThereIsNoLoader(t *testing.T) {
|
||||
got := bootOf(t, t.TempDir())
|
||||
if got.Loader != "none" || got.FallbackPresent || len(got.Entries) != 0 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
package main
|
||||
|
||||
// The boot-entries script as the mesh writes it, run with sh over a stand-in root (novox/hq ADR 0508 §9 and
|
||||
// §10, issue 1841). The fixtures are the shapes read from machines: a systemd-boot entries directory, a GRUB
|
||||
// menu, a hook that copies every kernel image to the default kernel's name, and the command line a machine
|
||||
// booted with.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const bootedCmdline = "initrd=\\initramfs-linux.img BOOT_IMAGE=/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3 initrd=\\intel-ucode.img\n"
|
||||
|
||||
const defaultEntry = "title Arch Linux\nlinux /vmlinuz-linux\ninitrd /intel-ucode.img\ninitrd /initramfs-linux.img\noptions root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet\n"
|
||||
|
||||
const loaderConf = "default arch.conf\ntimeout 3\nconsole-mode max\n"
|
||||
|
||||
// grubMenu is the part of a generated grub.cfg that names kernels, for a machine holding one kernel.
|
||||
const grubMenu = `menuentry 'Arch Linux' --class arch {
|
||||
set root='hd0,gpt2'
|
||||
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
|
||||
initrd /boot/initramfs-linux.img
|
||||
}
|
||||
menuentry 'Arch Linux, with fallback initramfs' --class arch {
|
||||
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
|
||||
initrd /boot/initramfs-linux-fallback.img
|
||||
}
|
||||
`
|
||||
|
||||
// kernelCopyingHook copies every installed kernel image to the default kernel's name on the boot partition.
|
||||
const kernelCopyingHook = `[Trigger]
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Type = Path
|
||||
Target = usr/lib/modules/*/vmlinuz
|
||||
|
||||
[Action]
|
||||
Description = Copying the kernel to the default name
|
||||
When = PostTransaction
|
||||
Exec = /usr/bin/sh -c '/usr/bin/cp /usr/lib/modules/$(uname -r)/vmlinuz /boot/vmlinuz-linux'
|
||||
`
|
||||
|
||||
// otherHook is a hook of the distribution's shape that has nothing to do with kernel images.
|
||||
const otherHook = `[Trigger]
|
||||
Operation = Upgrade
|
||||
Type = Package
|
||||
Target = pacman-mirrorlist
|
||||
|
||||
[Action]
|
||||
Description = Updating the mirror list
|
||||
When = PostTransaction
|
||||
Exec = /usr/bin/cp /etc/pacman.d/mirrorlist.pacnew /etc/pacman.d/mirrorlist
|
||||
`
|
||||
|
||||
type bootBed struct {
|
||||
script, root, state, bin, mkconfig string
|
||||
need string
|
||||
}
|
||||
|
||||
func newBootBed(t *testing.T) bootBed {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
b := bootBed{script: filepath.Join(dir, "boot-entries"), root: filepath.Join(dir, "root"), state: filepath.Join(dir, "state"),
|
||||
bin: filepath.Join(dir, "bin"), mkconfig: filepath.Join(dir, "mkconfig")}
|
||||
content := manifest(t).resource(t, "boot-entries").text(t)
|
||||
content = strings.ReplaceAll(content, "${dir:state}", b.state)
|
||||
if strings.Contains(content, "${") {
|
||||
t.Fatalf("a substitution the test does not make is left in the script")
|
||||
}
|
||||
for _, d := range []string{b.root + "/boot", b.root + "/proc", b.root + "/etc/pacman.d/hooks", b.state, b.bin} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(t, b.script, content, 0o755)
|
||||
write(t, b.root+"/proc/cmdline", bootedCmdline, 0o644)
|
||||
// the stand-in grub-mkconfig records the kernel it was told to put first, then its arguments.
|
||||
write(t, filepath.Join(b.bin, "grub-mkconfig"), "#!/bin/sh\necho \"top=$GRUB_TOP_LEVEL $@\" >>"+b.mkconfig+"\n", 0o755)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b bootBed) put(t *testing.T, p, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(b.root, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, full, content, 0o644)
|
||||
}
|
||||
|
||||
func (b bootBed) systemdBoot(t *testing.T) {
|
||||
b.put(t, "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f", "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
|
||||
b.put(t, "boot/loader/loader.conf", loaderConf)
|
||||
b.put(t, "boot/loader/entries/arch.conf", defaultEntry)
|
||||
b.put(t, "boot/vmlinuz-linux", "k")
|
||||
}
|
||||
|
||||
func (b bootBed) run(t *testing.T) (string, error) {
|
||||
t.Helper()
|
||||
args := []string{b.script, "run", b.root, b.state}
|
||||
if b.need != "" {
|
||||
args = append(args, b.need)
|
||||
}
|
||||
cmd := exec.Command("sh", args...)
|
||||
cmd.Env = append(os.Environ(), "PATH="+b.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (b bootBed) read(t *testing.T, p string) string {
|
||||
t.Helper()
|
||||
raw, _ := os.ReadFile(filepath.Join(b.root, p))
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestASystemdBootMachineGetsTheFallbackEntryFromItsBootedCommandLine(t *testing.T) {
|
||||
b := newBootBed(t)
|
||||
b.systemdBoot(t)
|
||||
b.put(t, "boot/intel-ucode.img", "u")
|
||||
out, err := b.run(t)
|
||||
if err != nil || !strings.Contains(out, "wrote") {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
want := "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\ninitrd /intel-ucode.img\ninitrd /initramfs-linux-lts.img\n" +
|
||||
"options root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3\n"
|
||||
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); got != want {
|
||||
t.Fatalf("entry:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
// The default entry and the loader's own file are as they were.
|
||||
if b.read(t, "boot/loader/entries/arch.conf") != defaultEntry || b.read(t, "boot/loader/loader.conf") != loaderConf {
|
||||
t.Fatalf("the default entry or loader.conf was touched")
|
||||
}
|
||||
if out, err := b.run(t); err != nil || !strings.Contains(out, "as it was") {
|
||||
t.Fatalf("again: %v %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutMicrocodeThereIsNoMicrocodeLine(t *testing.T) {
|
||||
b := newBootBed(t)
|
||||
b.systemdBoot(t)
|
||||
if out, err := b.run(t); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); strings.Contains(got, "ucode") || !strings.Contains(got, "initrd /initramfs-linux-lts.img") {
|
||||
t.Fatalf("entry: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGrubMachineIsGeneratedAgainOnlyWhenItsKernelsChanged(t *testing.T) {
|
||||
b := newBootBed(t)
|
||||
b.put(t, "boot/grub/grub.cfg", grubMenu)
|
||||
b.put(t, "boot/vmlinuz-linux", "k")
|
||||
if out, err := b.run(t); err != nil || !strings.Contains(out, "left as it is") {
|
||||
t.Fatalf("same: %v %s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(b.mkconfig); err == nil {
|
||||
t.Fatalf("the menu was generated again with the same kernels")
|
||||
}
|
||||
b.put(t, "boot/vmlinuz-linux-lts", "k")
|
||||
if out, err := b.run(t); err != nil || !strings.Contains(out, "generated again") {
|
||||
t.Fatalf("changed: %v %s", err, out)
|
||||
}
|
||||
raw, _ := os.ReadFile(b.mkconfig)
|
||||
if string(raw) != "top=/boot/vmlinuz-linux -o "+filepath.Join(b.root, "boot/grub/grub.cfg")+"\n" {
|
||||
t.Fatalf("grub-mkconfig was asked %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0508 §9: GRUB's menu lists kernels in reverse version order, so linux-lts would come first and become the
|
||||
// default entry (GRUB_DEFAULT=0); the kernel the machine booted is put first, and the fallback kernel never.
|
||||
func TestGrubKeepsTheBootedKernelFirstAndNeverTheFallback(t *testing.T) {
|
||||
for name, c := range map[string]struct{ cmdline, want string }{
|
||||
"booted from the default kernel": {"BOOT_IMAGE=/vmlinuz-linux root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
||||
"booted from a grub device path": {"BOOT_IMAGE=(hd0,msdos1)/vmlinuz-linux-zen root=UUID=x rw\n", "top=/boot/vmlinuz-linux-zen"},
|
||||
"booted from the fallback kernel": {"BOOT_IMAGE=/vmlinuz-linux-lts root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
||||
"no image on the command line": {"root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
||||
"an image no longer in /boot": {"BOOT_IMAGE=/vmlinuz-linux-gone root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
||||
} {
|
||||
b := newBootBed(t)
|
||||
b.put(t, "boot/grub/grub.cfg", grubMenu)
|
||||
b.put(t, "proc/cmdline", c.cmdline)
|
||||
for _, k := range []string{"vmlinuz-linux", "vmlinuz-linux-lts", "vmlinuz-linux-zen"} {
|
||||
b.put(t, "boot/"+k, "k")
|
||||
}
|
||||
if out, err := b.run(t); err != nil {
|
||||
t.Fatalf("%s: %v %s", name, err, out)
|
||||
}
|
||||
raw, _ := os.ReadFile(b.mkconfig)
|
||||
if !strings.HasPrefix(string(raw), c.want+" -o ") {
|
||||
t.Errorf("%s: grub-mkconfig was asked %q, want %s first", name, raw, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMachineWithNoLoaderFailsLoudly(t *testing.T) {
|
||||
b := newBootBed(t)
|
||||
out, err := b.run(t)
|
||||
if err == nil || !strings.Contains(out, "no boot loader found") {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABootPartitionWithoutRoomFailsBeforeAnythingIsWritten(t *testing.T) {
|
||||
b := newBootBed(t)
|
||||
b.systemdBoot(t)
|
||||
b.need = "999999999999"
|
||||
out, err := b.run(t)
|
||||
if err == nil || !strings.Contains(out, "free room there before the kernel is installed") {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
if b.read(t, "boot/loader/entries/mesh-linux-lts.conf") != "" {
|
||||
t.Fatalf("an entry was written")
|
||||
}
|
||||
// Once the fallback kernel is in place the room is not asked for again.
|
||||
b.put(t, "boot/vmlinuz-linux-lts", "k")
|
||||
if out, err := b.run(t); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHookThatCopiesKernelsToOneFixedNameIsMovedIntoTheStateAndNamed(t *testing.T) {
|
||||
b := newBootBed(t)
|
||||
b.systemdBoot(t)
|
||||
b.put(t, "etc/pacman.d/hooks/60-copy-kernel.hook", kernelCopyingHook)
|
||||
b.put(t, "etc/pacman.d/hooks/70-mirrorlist.hook", otherHook)
|
||||
b.put(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", "[Trigger]\nTarget = usr/lib/modules/*/vmlinuz\n[Action]\nExec = /bin/cp x /boot/y\n")
|
||||
out, err := b.run(t)
|
||||
if err != nil || !strings.Contains(out, "taken over: the pacman hook 60-copy-kernel.hook") {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
if b.read(t, "etc/pacman.d/hooks/60-copy-kernel.hook") != "" {
|
||||
t.Fatalf("the copying hook is still in the hook directory")
|
||||
}
|
||||
if raw, _ := os.ReadFile(filepath.Join(b.state, "taken-over", "60-copy-kernel.hook")); string(raw) != kernelCopyingHook {
|
||||
t.Fatalf("moved hook: %q", raw)
|
||||
}
|
||||
if b.read(t, "etc/pacman.d/hooks/70-mirrorlist.hook") != otherHook || b.read(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook") == "" {
|
||||
t.Fatalf("a hook that copies no kernel was moved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBootEntriesUnitAndHookAreTheModules(t *testing.T) {
|
||||
m := manifest(t)
|
||||
unit := m.resource(t, "boot-unit").text(t)
|
||||
for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/boot-entries run", "ProtectSystem=strict",
|
||||
"ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks", "NoNewPrivileges=yes", "WantedBy=multi-user.target"} {
|
||||
if !strings.Contains(unit, line+"\n") {
|
||||
t.Errorf("the unit lacks %q", line)
|
||||
}
|
||||
}
|
||||
hook := m.resource(t, "boot-hook")
|
||||
text := hook.text(t)
|
||||
if hook["path"] != "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook" || !strings.Contains(text, "Target = usr/lib/modules/*/vmlinuz\n") ||
|
||||
!strings.Contains(text, "When = PostTransaction\n") || !strings.Contains(text, "Exec = ${dir:state}/boot-entries run\n") {
|
||||
t.Fatalf("the hook: %v\n%s", hook["path"], text)
|
||||
}
|
||||
apply := m.resource(t, "boot-apply")
|
||||
if apply["unit"] != "mesh-kernel-boot-entries.service" || apply["state"] != "running" || apply["boot"] != "enabled" {
|
||||
t.Fatalf("apply: %v", apply)
|
||||
}
|
||||
}
|
||||
|
||||
// The fallback kernel and its headers are declared after the boot-entries service, so the entry is placed
|
||||
// before the kernel is installed and every DKMS module is built for it (ADR 0508 §9).
|
||||
func TestTheFallbackKernelAndItsHeadersAreDeclaredAfterTheBootEntriesUnit(t *testing.T) {
|
||||
m := manifest(t)
|
||||
at := map[string]int{}
|
||||
for i, r := range m.Resources {
|
||||
at[r["id"].(string)] = i
|
||||
}
|
||||
for id, pkg := range map[string]string{"linux-lts": "linux-lts", "linux-lts-headers": "linux-lts-headers"} {
|
||||
r := m.resource(t, id)
|
||||
if r["type"] != "package" || r["package"] != pkg || r["absent"] == true {
|
||||
t.Errorf("%s: %v", id, r)
|
||||
}
|
||||
if at[id] < at["boot-apply"] || at[id] < at["boot-hook"] {
|
||||
t.Errorf("%s is declared before the boot-entries unit and hook", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,15 +46,5 @@ func tools(m *Machine) []stdio.Tool {
|
||||
return m.HugePages(n)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "kernel_boot",
|
||||
Description: "How this machine boots and whether the fallback kernel is on its menu (novox/hq ADR 0508 §9): the loader that booted it " +
|
||||
"(systemd-boot, read from the EFI variable it sets, grub, or none), the menu's entries, the kernels in /boot, the entry " +
|
||||
"the machine booted (systemd-boot) or the kernel image it booted (grub), whether the fallback kernel linux-lts is on " +
|
||||
"the menu with its kernel in /boot, and the room free on /boot in KiB (the fallback kernel needs about 72 MB). " +
|
||||
"Replaces ls /boot, bootctl list, reading grub.cfg and df /boot. (r)",
|
||||
Input: schema(map[string]any{}),
|
||||
Run: func(args map[string]any) (any, error) { return m.Boot() },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
|
||||
for _, r := range m.Resources {
|
||||
ids = append(ids, r["id"].(string))
|
||||
}
|
||||
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" {
|
||||
if strings.Join(ids, " ") != "state huge-pages unit apply" {
|
||||
t.Fatalf("order: %v", ids)
|
||||
}
|
||||
script := m.resource(t, "huge-pages")
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Module kernel, novox/hq ADR 0508 §9, issue 1841. After a kernel image is installed, upgraded or removed, the
|
||||
# fallback kernel's boot entry (or the GRUB menu) is placed again. Written by the mesh; change the module.
|
||||
[Trigger]
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Operation = Remove
|
||||
Type = Path
|
||||
Target = usr/lib/modules/*/vmlinuz
|
||||
|
||||
[Action]
|
||||
Description = Placing the fallback kernel on the boot menu
|
||||
When = PostTransaction
|
||||
Exec = ${dir:state}/boot-entries run
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The fallback kernel's place on this machine's boot menu (module kernel, novox/hq ADR 0508 §9 and §10,
|
||||
# issue 1841). Written by the mesh and run as root by mesh-kernel-boot-entries.service at boot and when the
|
||||
# module is applied, and by the pacman hook 90-mesh-kernel-boot-entries.hook after a kernel is installed,
|
||||
# upgraded or removed. Replaced on every push; change the module, never this file.
|
||||
#
|
||||
# boot-entries run [<root> [<this module's state> [<room needed in /boot, KiB>]]]
|
||||
#
|
||||
# The three are arguments only so the module's tests can run this against a stand-in tree.
|
||||
#
|
||||
# It reads from the machine which loader booted it, never from a setting:
|
||||
# systemd-boot the EFI variable LoaderEntrySelected is present: the entry mesh-linux-lts.conf is written
|
||||
# from the command line the machine booted with.
|
||||
# GRUB /boot/grub/grub.cfg exists and that variable does not: the menu is generated again, only
|
||||
# when the set of kernels in /boot differs from the menu's.
|
||||
# neither it fails loudly. The default entry is never touched.
|
||||
# And a pacman hook of someone else's that copies kernel images to one fixed name in /boot is moved into this
|
||||
# module's state first (ADR 0508 §10): it would put the fallback kernel in the default kernel's place.
|
||||
set -eu
|
||||
|
||||
root=/
|
||||
state='${dir:state}'
|
||||
[ $# -ge 2 ] && root=$(printf '%s' "$2" | sed 's|/$||')
|
||||
[ $# -ge 3 ] && state=$3
|
||||
boot=$root/boot
|
||||
hooks=$root/etc/pacman.d/hooks
|
||||
efivar=$root/sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f
|
||||
ours=90-mesh-kernel-boot-entries.hook
|
||||
# The fallback kernel and its initramfs are about 72 MB in /boot (issue 1836): less room fails the unit
|
||||
# before a package writes half of them.
|
||||
need_kib=98304
|
||||
[ $# -ge 4 ] && need_kib=$4
|
||||
|
||||
[ $# -ge 1 ] && [ "$1" = run ] || {
|
||||
echo "usage: boot-entries run [<root> [<state directory> [<KiB needed>]]]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# A hook that is not ours, triggered by kernel images, whose action copies to one fixed name in /boot.
|
||||
copies_kernels() {
|
||||
grep -Eqi '^[[:space:]]*Target[[:space:]]*=.*(vmlinuz|usr/lib/modules)' "$1" &&
|
||||
grep -Eq '^[[:space:]]*Exec[[:space:]]*=.*(cp|install|ln|rsync)[[:space:]].*/boot/[A-Za-z0-9._+-]+([^A-Za-z0-9._+/%$*-]|$)' "$1"
|
||||
}
|
||||
|
||||
if [ -d "$hooks" ]; then
|
||||
for hook in "$hooks"/*.hook; do
|
||||
[ -f "$hook" ] || continue
|
||||
[ "$(basename "$hook")" = "$ours" ] && continue
|
||||
copies_kernels "$hook" || continue
|
||||
mkdir -p "$state/taken-over"
|
||||
mv -f "$hook" "$state/taken-over/$(basename "$hook")"
|
||||
echo "taken over: the pacman hook $(basename "$hook") copied kernel images to one fixed name in /boot; moved to $state/taken-over/ so each kernel keeps its own package's name"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -e "$efivar" ]; then
|
||||
loader=systemd-boot
|
||||
elif [ -f "$boot/grub/grub.cfg" ]; then
|
||||
loader=grub
|
||||
else
|
||||
echo "no boot loader found: the machine was not booted by systemd-boot (no LoaderEntrySelected variable) and there is no $boot/grub/grub.cfg; the fallback kernel has no menu to be placed on" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Room first, only while the fallback kernel is not in /boot yet.
|
||||
if [ ! -e "$boot/vmlinuz-linux-lts" ]; then
|
||||
free=$(df -Pk "$boot" | awk 'NR==2 {print $4}')
|
||||
[ -n "$free" ] || free=0
|
||||
if [ "$free" -lt "$need_kib" ]; then
|
||||
echo "$boot has $free KiB free and the fallback kernel needs $need_kib KiB: free room there before the kernel is installed" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$loader" in
|
||||
systemd-boot)
|
||||
entries=$boot/loader/entries
|
||||
cmdline=$(sed -e 's/BOOT_IMAGE=[^ ]*//g' -e 's/initrd=[^ ]*//g' "$root/proc/cmdline" | tr -s ' \n' ' ' | sed -e 's/^ //' -e 's/ $//')
|
||||
[ -n "$cmdline" ] || {
|
||||
echo "$root/proc/cmdline holds no command line to boot the fallback kernel with" >&2
|
||||
exit 1
|
||||
}
|
||||
want=$(
|
||||
echo "title Arch Linux (linux-lts, fallback)"
|
||||
echo "linux /vmlinuz-linux-lts"
|
||||
for ucode in intel-ucode.img amd-ucode.img; do
|
||||
[ -e "$boot/$ucode" ] && echo "initrd /$ucode"
|
||||
done
|
||||
echo "initrd /initramfs-linux-lts.img"
|
||||
echo "options $cmdline"
|
||||
)
|
||||
mkdir -p "$entries"
|
||||
if [ "$(cat "$entries/mesh-linux-lts.conf" 2>/dev/null || true)" = "$want" ]; then
|
||||
echo "systemd-boot: mesh-linux-lts.conf, as it was"
|
||||
else
|
||||
printf '%s\n' "$want" >"$entries/mesh-linux-lts.conf"
|
||||
echo "systemd-boot: wrote $entries/mesh-linux-lts.conf from the command line the machine booted with; the default entry is as it was"
|
||||
fi
|
||||
;;
|
||||
grub)
|
||||
cfg=$boot/grub/grub.cfg
|
||||
menu=$(sed -n 's|^[[:space:]]*linux[[:space:]]*\(/boot\)\{0,1\}/\(vmlinuz-[^[:space:]]*\).*|\2|p' "$cfg" | sort -u)
|
||||
have=$(cd "$boot" && ls -d vmlinuz-* 2>/dev/null | sort -u || true)
|
||||
if [ "$menu" = "$have" ]; then
|
||||
echo "grub: the menu lists the kernels in /boot, left as it is"
|
||||
else
|
||||
# GRUB lists kernels in reverse version order, so the fallback kernel would come first and become the
|
||||
# default entry: the kernel the machine booted is put first (GRUB_TOP_LEVEL; /etc/default/grub still
|
||||
# overrides it when it sets one), the fallback kernel never.
|
||||
top=$(tr ' ' '\n' <"$root/proc/cmdline" | sed -n 's|^BOOT_IMAGE=\(.*\)$|\1|p' | head -n 1)
|
||||
top=$(printf '%s' "$top" | sed 's|.*/||')
|
||||
case "$top" in
|
||||
vmlinuz-linux-lts | "") top= ;;
|
||||
esac
|
||||
[ -n "$top" ] && [ -e "$boot/$top" ] || top=
|
||||
if [ -z "$top" ]; then
|
||||
if [ -e "$boot/vmlinuz-linux" ]; then
|
||||
top=vmlinuz-linux
|
||||
else
|
||||
top=$(printf '%s\n' $have | grep -v '^vmlinuz-linux-lts$' | head -n 1 || true)
|
||||
fi
|
||||
fi
|
||||
if [ -n "$top" ]; then
|
||||
GRUB_TOP_LEVEL=/boot/$top grub-mkconfig -o "$cfg"
|
||||
else
|
||||
grub-mkconfig -o "$cfg"
|
||||
fi
|
||||
first=$top
|
||||
[ -n "$first" ] || first="the menu's own order"
|
||||
echo "grub: the kernels in /boot changed ($(echo $have | tr '\n' ' ')); the menu is generated again with $first first; the default entry is as it was"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -1,22 +0,0 @@
|
||||
[Unit]
|
||||
Description=The fallback kernel's place on this machine's boot menu
|
||||
# Module kernel, novox/hq ADR 0508 §9, issue 1841. Starting this unit reads which loader booted the machine and
|
||||
# places the fallback kernel's boot entry (systemd-boot) or generates the menu again (GRUB); it fails, and says
|
||||
# why, when there is no loader or the boot partition has no room. The kernel packages are declared after this
|
||||
# unit, so the entry is in place before they are installed.
|
||||
After=local-fs.target
|
||||
Wants=local-fs.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=${dir:state}/boot-entries run
|
||||
# It writes the boot partition, the pacman hook directory and its own state, and nothing else.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks
|
||||
ProtectHome=yes
|
||||
PrivateNetwork=yes
|
||||
NoNewPrivileges=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -13,8 +13,7 @@
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
"kernel_huge_pages",
|
||||
"kernel_boot"
|
||||
"kernel_huge_pages"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
@@ -54,52 +53,6 @@
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boot-entries",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/boot-entries",
|
||||
"mode": "0755",
|
||||
"trusted": true,
|
||||
"template": "files/boot-entries.sh"
|
||||
},
|
||||
{
|
||||
"id": "boot-unit",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/mesh-kernel-boot-entries.service",
|
||||
"mode": "0644",
|
||||
"template": "files/mesh-kernel-boot-entries.service"
|
||||
},
|
||||
{
|
||||
"id": "boot-hook",
|
||||
"type": "file",
|
||||
"path": "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook",
|
||||
"mode": "0644",
|
||||
"template": "files/90-mesh-kernel-boot-entries.hook"
|
||||
},
|
||||
{
|
||||
"id": "boot-apply",
|
||||
"type": "service",
|
||||
"unit": "mesh-kernel-boot-entries.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"boot-unit",
|
||||
"boot-entries"
|
||||
],
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "linux-lts",
|
||||
"type": "package",
|
||||
"package": "linux-lts"
|
||||
},
|
||||
{
|
||||
"id": "linux-lts-headers",
|
||||
"type": "package",
|
||||
"package": "linux-lts-headers"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
@@ -52,7 +52,7 @@ type guardWorld struct {
|
||||
|
||||
func newGuardWorld(t *testing.T) *guardWorld {
|
||||
w := &guardWorld{now: time.Date(2026, 10, 5, 23, 55, 0, 0, time.UTC)}
|
||||
w.f = newFakeKeycloak(t, "NodeC", "old-password-from-2022")
|
||||
w.f = newFakeKeycloak(t, "Novox", "old-password-from-2022")
|
||||
w.x = &fakeExec{f: w.f}
|
||||
w.g = &Guard{KC: w.f.client("the-mesh-minted-this"), Exec: w.x,
|
||||
Now: func() time.Time { return w.now },
|
||||
@@ -195,7 +195,7 @@ func TestWhileTheAdminIsRefusedTheProvisionerDoesNotAskKeycloak(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
w.x.fails = true
|
||||
w.g.Ensure(ctx, true, false)
|
||||
a := provisioner{clients: OidcClients{KC: w.g.KC, Realm: "NodeC"}, guard: w.g,
|
||||
a := provisioner{clients: OidcClients{KC: w.g.KC, Realm: "Novox"}, guard: w.g,
|
||||
announce: func(string, map[string]any) {}, log: func(string, ...any) {}}
|
||||
logins := w.f.logins
|
||||
err := a.Create(ctx, grafana("s", nil))
|
||||
|
||||
@@ -91,13 +91,13 @@ func movedWorld(t *testing.T, h *harness.Harness) (f *fakeKeycloak, start func()
|
||||
}
|
||||
}
|
||||
|
||||
// 1332/AC1: a consumer only the grant file gives (oidc-client for node-c.mesh-mail-web, not moved) is wanted and
|
||||
// 1332/AC1: a consumer only the grant file gives (oidc-client for novox.mesh-mail-web, not moved) is wanted and
|
||||
// Adapter.Create is asked for its client, though keycloak runs on the moved path.
|
||||
func TestAGrantFileConsumerNotMovedIsMadeOnTheMovedPath(t *testing.T) {
|
||||
mailWeb := "mesh_node_c_mail_web"
|
||||
receives := grantGives(t, map[string]any{"as": mailWeb, "node": "node-c", "values": map[string]any{
|
||||
mailWeb := "mesh_novox_mail_web"
|
||||
receives := grantGives(t, map[string]any{"as": mailWeb, "node": "novox", "values": map[string]any{
|
||||
"label": "mail-web", "endpoint": "web", "callback": "/login/generic_oauth", "name": "mail.example.org"}})
|
||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "node-c"}
|
||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "novox"}
|
||||
f, start := movedWorld(t, h)
|
||||
start()
|
||||
waitUntil(t, time.Second, func() bool {
|
||||
@@ -113,13 +113,13 @@ func TestAGrantFileConsumerNotMovedIsMadeOnTheMovedPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// 1332/AC2: a grant-file consumer whose client already exists in the backend (mesh_node_a_grafana) counts as asked and
|
||||
// 1332/AC2: a grant-file consumer whose client already exists in the backend (mesh_ace_grafana) counts as asked and
|
||||
// is not held for retirement on the moved path.
|
||||
func TestAGrantFileConsumerAlreadyInKeycloakIsNotRetiredOnTheMovedPath(t *testing.T) {
|
||||
grafana := "mesh_node_a_grafana"
|
||||
receives := grantGives(t, map[string]any{"as": grafana, "node": "node-a", "values": map[string]any{
|
||||
grafana := "mesh_ace_grafana"
|
||||
receives := grantGives(t, map[string]any{"as": grafana, "node": "ace", "values": map[string]any{
|
||||
"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth", "name": "grafana.example.org"}})
|
||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "node-a",
|
||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "ace",
|
||||
StablePasses: 1, StableFor: time.Millisecond}
|
||||
f, start := movedWorld(t, h)
|
||||
// Seed the backend with the client the mesh made earlier, exactly as grafana's already is — before anything
|
||||
|
||||
@@ -8,7 +8,7 @@ import "testing"
|
||||
// 1332/AC3: the moved-path harness, built from the environment, reads MESH_RECEIVES the way the grant path does
|
||||
// (main.go's other branch, line 67), as postgres's moved harness does (postgres-provider/main.go:43).
|
||||
func TestTheMovedPathHarnessReadsMESH_RECEIVESAsTheGrantPathDoes(t *testing.T) {
|
||||
env := map[string]string{"MESH_RECEIVES": "/grants/mesh.json", "MESH_NODE": "node-c"}
|
||||
env := map[string]string{"MESH_RECEIVES": "/grants/mesh.json", "MESH_NODE": "novox"}
|
||||
getenv := func(k string) string { return env[k] }
|
||||
h := movedHarness(getenv, provisioner{})
|
||||
if h.Receives != env["MESH_RECEIVES"] {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/provider/harness"
|
||||
)
|
||||
|
||||
const testIssuer = "https://id.example.org/realms/NodeC"
|
||||
const testIssuer = "https://id.example.org/realms/Novox"
|
||||
|
||||
// keycloak's own part of the moved path (issue 849): the shared driving of it is proven in the SDK's harness package.
|
||||
|
||||
@@ -143,12 +143,12 @@ func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) {
|
||||
})
|
||||
viaFile := made(func(p provisioner) {
|
||||
list, _ := json.Marshal([]provider.ConsumerCredential{{Consumer: "home", Module: "grafana",
|
||||
Credential: provider.PairName("node-c", ProvisionName, "home", "grafana", ""), Value: "pw-" + grafana}})
|
||||
Credential: provider.PairName("novox", ProvisionName, "home", "grafana", ""), Value: "pw-" + grafana}})
|
||||
file := filepath.Join(t.TempDir(), "consumers.json")
|
||||
if err := os.WriteFile(file, list, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &harness.Harness{Resource: ProvisionName, Adapter: p, Node: "node-c",
|
||||
h := &harness.Harness{Resource: ProvisionName, Adapter: p, Node: "novox",
|
||||
Now: func() time.Time { return now }, Log: func(string, ...any) {}}
|
||||
run, _, _, err := moved(file, h, testIssuer)
|
||||
if err != nil {
|
||||
@@ -156,10 +156,10 @@ func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) {
|
||||
}
|
||||
bus := &bucketBus{buckets: map[string]map[string][]byte{}}
|
||||
plan, _ := json.Marshal(map[string]any{"node": "home", "module": "grafana", "provisioning": map[string]any{
|
||||
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "node-c",
|
||||
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox",
|
||||
"provider-module": "keycloak", "contributes": grafanaContributes, "names": grafanaNames}}}})
|
||||
_ = bus.Put(ctx, provider.AssignmentsBucket, "home.grafana", plan)
|
||||
pr := provider.New(provider.Options{Node: "node-c", Module: "keycloak", Bus: bus,
|
||||
pr := provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: bus,
|
||||
Vault: provider.ConsumersFile{Paths: []string{file}},
|
||||
Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer,
|
||||
Serves: servedValues(testIssuer)}},
|
||||
|
||||
@@ -64,7 +64,7 @@ func RolesMapper() Rep {
|
||||
|
||||
var realmPath = regexp.MustCompile(`/realms/([^/]+)/?$`)
|
||||
|
||||
// RealmOf is the realm named by an issuer URL — `https://id.example/realms/NodeC` is realm `NodeC`.
|
||||
// RealmOf is the realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`.
|
||||
// The issuer is the one value an assignment sets, so the realm is read out of it rather than set a
|
||||
// second time where the two could disagree.
|
||||
func RealmOf(issuer string) (string, error) {
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
)
|
||||
|
||||
func oidcWorld(t *testing.T) (*fakeKeycloak, OidcClients) {
|
||||
f := newFakeKeycloak(t, "NodeC", "pw")
|
||||
return f, OidcClients{KC: f.client("pw"), Realm: "NodeC"}
|
||||
f := newFakeKeycloak(t, "Novox", "pw")
|
||||
return f, OidcClients{KC: f.client("pw"), Realm: "Novox"}
|
||||
}
|
||||
|
||||
// grafana is a dashboard on the home server, as the mesh hands it to the provisioner.
|
||||
@@ -53,8 +53,8 @@ func mustHold(t *testing.T, o OidcClients, p harness.Provision, want bool) {
|
||||
|
||||
func TestTheRealmIsReadOutOfTheIssuer(t *testing.T) {
|
||||
for issuer, want := range map[string]string{
|
||||
"https://id.example.org/realms/NodeC": "NodeC",
|
||||
"https://id.example.org/realms/NodeC/": "NodeC",
|
||||
"https://id.example.org/realms/Novox": "Novox",
|
||||
"https://id.example.org/realms/Novox/": "Novox",
|
||||
"http://127.0.0.1:18500/realms/master": "master",
|
||||
} {
|
||||
if got, err := RealmOf(issuer); err != nil || got != want {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
MESH_LAB_FORGE=${config:forge}
|
||||
MESH_LAB_FORGE_OWNER=${config:forge-owner}
|
||||
MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json
|
||||
MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret
|
||||
|
||||
@@ -37,12 +37,6 @@
|
||||
"description": "The forge the lab's machines take their packages from, as its address: the operator's, with no default (novox/hq ADR 0112).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"forge-owner": {
|
||||
"type": "string",
|
||||
"description": "The owner on the forge the mesh's repositories and npm packages are under, such as the organisation that holds them: the operator's, with no default (novox/hq ADR 0507 §9).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
|
||||
@@ -60,8 +60,8 @@ test("no run starts while the base image is rebuilt", () => {
|
||||
});
|
||||
|
||||
test("a rebuild is mesh-lab's base build from the forge's main, with no secret in its script", () => {
|
||||
const s = baseScript("/work/base-x", "https://forge.invalid", "mesh-org", "/work/.base-x.git-credentials");
|
||||
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/mesh-org\/mesh-lab.git'/);
|
||||
const s = baseScript("/work/base-x", "https://forge.invalid", "/work/.base-x.git-credentials");
|
||||
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/);
|
||||
assert.match(s, /src\/cli.ts base build/);
|
||||
assert.ok(s.indexOf("rm -f '/work/.base-x.git-credentials'") < s.indexOf("npm ci"), "the credential outlives the clone");
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// The forge's owner differs between meshes (novox/hq ADR 0507 §9, issue 1838): the lab takes it as a config value
|
||||
// the operator gives, with no default, and no file of the lab names it.
|
||||
// 1838/AC4
|
||||
|
||||
const here = join(import.meta.dirname, "..");
|
||||
|
||||
test("the forge's owner is a required config value with no default, and reaches the lab's env-file", () => {
|
||||
const manifest = JSON.parse(readFileSync(join(here, "module.json"), "utf8")) as { config: Record<string, Record<string, unknown>> };
|
||||
const owner = manifest.config["forge-owner"];
|
||||
assert.ok(owner, "the manifest declares no forge-owner");
|
||||
assert.equal(owner.required, true);
|
||||
assert.ok(!("default" in owner), `forge-owner has the default ${JSON.stringify(owner.default)}`);
|
||||
assert.match(readFileSync(join(here, "files", "lab.env"), "utf8"), /^MESH_LAB_FORGE_OWNER=\$\{config:forge-owner\}$/m);
|
||||
});
|
||||
|
||||
test("no file of the lab's code names an owner on the forge", () => {
|
||||
for (const f of readdirSync(join(here, "tools")).filter((f) => f.endsWith(".ts"))) {
|
||||
const src = readFileSync(join(here, "tools", f), "utf8");
|
||||
assert.doesNotMatch(src, /\}\/[a-z][\w.-]*\/\$\{repo\}|\}\/[a-z][\w.-]*\/mesh-lab\.git|\/api\/packages\/[a-z][\w.-]*\/npm\//, f);
|
||||
}
|
||||
});
|
||||
@@ -92,8 +92,8 @@ test("a run fails at its setup, naming the checkout, when nothing of it can be i
|
||||
// checkouts are.
|
||||
test("a run checks out mesh-tools-go and says where its checkouts are", () => {
|
||||
assert.ok(REPOSITORIES.includes("mesh-tools-go"));
|
||||
const s = script("/work", "run-x", "http://forge", "mesh-org", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
|
||||
assert.match(s, /mesh-org\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
|
||||
const s = script("/work", "run-x", "http://forge", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
|
||||
assert.match(s, /novox\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
|
||||
assert.match(s, /export MESH_LAB_REPOS='\/work\/run-x'/);
|
||||
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
|
||||
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
|
||||
@@ -113,22 +113,22 @@ function aBinding(as: string, secret: string): { binding: string; secret: string
|
||||
}
|
||||
|
||||
test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => {
|
||||
const b = aBinding("lab-node-d", "s3cret-of-forty-characters");
|
||||
assert.equal(forgeCredential("http://node-c.internal:20000", b.binding, b.secret),
|
||||
"http://lab-node-d:s3cret-of-forty-characters@node-c.internal:20000");
|
||||
assert.equal(forgeCredential("http://node-c.internal:20000/", b.binding, ""), undefined);
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret),
|
||||
"http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined);
|
||||
assert.equal(forgeCredential("", b.binding, b.secret), undefined);
|
||||
assert.equal(forgeCredential("http://node-c.internal:20000", "/nowhere", b.secret), undefined);
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined);
|
||||
});
|
||||
|
||||
test("a run clones through git's credential store, and its script carries no secret", () => {
|
||||
const b = aBinding("lab-node-d", "s3cret-of-forty-characters");
|
||||
const credential = forgeCredential("http://node-c.internal:20000", b.binding, b.secret);
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret);
|
||||
const path = credentialPath("/work", "run-x");
|
||||
const s = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, path);
|
||||
const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path);
|
||||
assert.ok(credential);
|
||||
assert.doesNotMatch(s, /s3cret/);
|
||||
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/node-c\.internal:20000\/mesh-org\/mesh-tools-go\.git'/);
|
||||
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/);
|
||||
assert.match(s, /export GIT_TERMINAL_PROMPT=0/);
|
||||
assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory");
|
||||
assert.equal(credentialArgs(undefined), "");
|
||||
@@ -139,10 +139,10 @@ test("a run clones through git's credential store, and its script carries no sec
|
||||
test("git presents the stored credential to the forge, and only when one is given", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-git-"));
|
||||
const store = join(dir, "git-credentials");
|
||||
writeFileSync(store, "http://lab-node-d:pw@forge.invalid:20000\n", { mode: 0o600 });
|
||||
writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 });
|
||||
const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
assert.match(fill.stdout, /username=lab-node-d/);
|
||||
assert.match(fill.stdout, /username=lab-shanks/);
|
||||
assert.match(fill.stdout, /password=pw/);
|
||||
const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
@@ -168,7 +168,7 @@ test("a stopped script removes the credential, whatever ends it", () => {
|
||||
});
|
||||
|
||||
test("the script removes the credential as soon as its clones are done", () => {
|
||||
const s = script("/work", "run-x", "http://forge", "mesh-org", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
|
||||
const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
|
||||
const lastClone = s.lastIndexOf("clone --quiet");
|
||||
const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone);
|
||||
const building = s.indexOf("npm ci");
|
||||
@@ -252,10 +252,10 @@ test("the lab's tools bundle names its bound credential and binding in its env",
|
||||
assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]);
|
||||
});
|
||||
|
||||
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on node-d): npm 12 refused
|
||||
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused
|
||||
// mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's
|
||||
// address (http://node-c.internal:20000/…), the lock file holds the address the forge writes every tarball
|
||||
// the forge's URL, and npm 12 installs a locked tarball only from its configured registry.
|
||||
// address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball
|
||||
// URL at (https://git.novox.be/…), and npm 12 installs a locked tarball only from its configured registry.
|
||||
// A run names the registry by the address the registry names itself by.
|
||||
import { npmRegistry } from "../tools/runs.ts";
|
||||
|
||||
@@ -270,54 +270,54 @@ function aRegistry(answer: () => Response | Promise<Response>): { get: typeof fe
|
||||
|
||||
const packument = (tarball: string) => new Response(JSON.stringify({
|
||||
name: "@novox/mesh-sdk", "dist-tags": { latest: "0.1.14" },
|
||||
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/mesh-org/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
|
||||
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/novox/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
|
||||
test("a run names the package registry by the address the registry gives its tarballs", async () => {
|
||||
const r = aRegistry(() => packument("https://forge.example.org/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
|
||||
const got = await npmRegistry("http://forge.example.org:3000/", "mesh-org", r.get, "https://forge.example.org/api/packages/mesh-org/npm/");
|
||||
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/mesh-org/npm/" });
|
||||
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/mesh-org/npm/@novox%2fmesh-sdk"]);
|
||||
const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
|
||||
const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/");
|
||||
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" });
|
||||
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]);
|
||||
// The forge's own scheme is taken too, with its port.
|
||||
const plain = await npmRegistry("http://forge.example.org:3000", "mesh-org",
|
||||
aRegistry(() => packument("http://forge.example.org:3000/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
|
||||
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/mesh-org/npm/" });
|
||||
const plain = await npmRegistry("http://forge.example.org:3000",
|
||||
aRegistry(() => packument("http://forge.example.org:3000/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
|
||||
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/novox/npm/" });
|
||||
});
|
||||
|
||||
// What the registry answers may come over plain HTTP, so it never decides where packages come from: only
|
||||
// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after
|
||||
// it (review of mesh-catalog #275, ADR 0346).
|
||||
test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => {
|
||||
const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
for (const [answer, why] of [
|
||||
[() => new Response("not found", { status: 404 }), /it answered 404/],
|
||||
[() => new Response("{}", { status: 200 }), /no tarball/],
|
||||
[() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/],
|
||||
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/mesh-org\/npm\//],
|
||||
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//],
|
||||
[() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/],
|
||||
[() => packument(`https://attacker.example.net/x?u=https://node-c.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`https://node-c.internal${sdk}?x=1`), /query or a fragment/],
|
||||
[() => packument(`https://node-c.internal${sdk}#frag`), /query or a fragment/],
|
||||
[() => packument(`https://attacker.example.net/#https://node-c.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`ftp://node-c.internal${sdk}`), /at ftp:\/\/node-c\.internal, which is neither/],
|
||||
[() => packument(`file:///api/packages/mesh-org/npm/x.tgz`), /which is neither/],
|
||||
[() => packument(`https://node-c.internal/api/packages/mesh-org/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
|
||||
[() => packument(`https://user:pw@node-c.internal${sdk}`), /user or password/],
|
||||
[() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/],
|
||||
[() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/],
|
||||
[() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/],
|
||||
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/],
|
||||
[() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
|
||||
[() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/],
|
||||
] as [() => Response, RegExp][]) {
|
||||
const got = await npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(answer).get);
|
||||
assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", String(why));
|
||||
const got = await npmRegistry("http://novox.internal:20000", aRegistry(answer).get);
|
||||
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", String(why));
|
||||
assert.match(got.problem ?? "", /did not give an address the lab takes/);
|
||||
assert.match(got.problem ?? "", why);
|
||||
}
|
||||
});
|
||||
|
||||
test("a run's npm configuration names the registry it was given, and the forge's own address only without one", () => {
|
||||
const given = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, undefined,
|
||||
"https://forge.example.org/api/packages/mesh-org/npm/");
|
||||
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/mesh-org\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
|
||||
assert.doesNotMatch(given, /@novox:registry=http:\/\/node-c\.internal/);
|
||||
const none = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {});
|
||||
assert.match(none, /'@novox:registry=http:\/\/node-c\.internal:20000\/api\/packages\/mesh-org\/npm\/'/);
|
||||
const given = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, undefined,
|
||||
"https://forge.example.org/api/packages/novox/npm/");
|
||||
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/novox\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
|
||||
assert.doesNotMatch(given, /@novox:registry=http:\/\/novox\.internal/);
|
||||
const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {});
|
||||
assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/);
|
||||
});
|
||||
|
||||
// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a
|
||||
@@ -325,20 +325,20 @@ test("a run's npm configuration names the registry it was given, and the forge's
|
||||
import { publicRootOf } from "../tools/runs.ts";
|
||||
|
||||
test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => {
|
||||
const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const root = "https://git.example.org/api/packages/mesh-org/npm/";
|
||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||
const at = (tarball: string, publicRoot?: string) =>
|
||||
npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(() => packument(tarball)).get, publicRoot);
|
||||
npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot);
|
||||
assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root });
|
||||
for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) {
|
||||
const got = await at(tarball, root);
|
||||
assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", tarball);
|
||||
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball);
|
||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball);
|
||||
}
|
||||
// Without a public root, the forge's public name is not trusted on its own word.
|
||||
assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/);
|
||||
// And the forge's own address only at its own port.
|
||||
assert.match((await at(`http://node-c.internal${sdk}`, root)).problem ?? "", /neither/);
|
||||
assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/);
|
||||
});
|
||||
|
||||
test("the public root is read from the binding, and only an https root at the npm path is taken", () => {
|
||||
@@ -346,39 +346,39 @@ test("the public root is read from the binding, and only an https root at the np
|
||||
const binding = (root: unknown) => {
|
||||
const f = join(dir, `b-${Math.random()}.json`);
|
||||
writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000,
|
||||
"npm-path": "/api/packages/mesh-org/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
|
||||
"npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
|
||||
return f;
|
||||
};
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/mesh-org/npm/"), "mesh-org"),
|
||||
{ root: "https://git.example.org/api/packages/mesh-org/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/mesh-org/npm/"), "mesh-org"),
|
||||
{ root: "https://git.example.org:8443/api/packages/mesh-org/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")),
|
||||
{ root: "https://git.example.org/api/packages/novox/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")),
|
||||
{ root: "https://git.example.org:8443/api/packages/novox/npm/" });
|
||||
for (const [root, why] of [
|
||||
[undefined, /serves no public-root/],
|
||||
["http://git.example.org/api/packages/mesh-org/npm/", /is not https:\/\/<host>/],
|
||||
["http://git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/mesh-org/npm/?x=1", /is not https:\/\/<host>/],
|
||||
["https://u:p@git.example.org/api/packages/mesh-org/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/mesh-org/npm/\nallow-remote=all", /whitespace or a control character/],
|
||||
["https://${bound:route:name-web}/api/packages/mesh-org/npm/", /not a URL|is not https/],
|
||||
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
|
||||
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/],
|
||||
["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/],
|
||||
] as [unknown, RegExp][]) {
|
||||
const got = publicRootOf(binding(root), "mesh-org");
|
||||
const got = publicRootOf(binding(root));
|
||||
assert.equal(got.root, undefined, String(root));
|
||||
assert.match(got.problem ?? "", why, String(root));
|
||||
}
|
||||
assert.match(publicRootOf(join(dir, "none.json"), "mesh-org").problem ?? "", /could not be read/);
|
||||
assert.match(publicRootOf(undefined, "mesh-org").problem ?? "", /no npm-package-registry binding/);
|
||||
assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/);
|
||||
assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
|
||||
// is "null", as is a `file:` forge's, and the two must not match.
|
||||
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
|
||||
const got = await npmRegistry("file:///srv/forge", "mesh-org", aRegistry(() => packument("file:///api/packages/mesh-org/npm/x.tgz")).get);
|
||||
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
|
||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
|
||||
const root = "https://git.example.org/api/packages/mesh-org/npm/";
|
||||
const viaRoot = await npmRegistry("ftp://node-c.internal", "mesh-org", aRegistry(() =>
|
||||
packument("ftp://node-c.internal/api/packages/mesh-org/npm/x.tgz")).get, root);
|
||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
|
||||
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
|
||||
assert.match(viaRoot.problem ?? "", /neither/);
|
||||
});
|
||||
|
||||
@@ -401,7 +401,7 @@ test("every file takes its text from a template in files/", () => {
|
||||
});
|
||||
|
||||
// Defends novox/hq issue 693: a run whose only test skipped as NOT REACHED read "0 passed, 0 failed", exit 0,
|
||||
// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on node-d wrote it.
|
||||
// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on shanks wrote it.
|
||||
const oldLog = [
|
||||
"STOPPED at step 16 of 24, base: \t\t\t\tMake sure you have all the referenced content and try again.",
|
||||
"\ufe63 genesis adopts its bus as the nats module in place: one bus, its streams kept, the node connected (588152.016874ms) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:",
|
||||
|
||||
+13
-23
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
checkDir, repositoryURL, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
||||
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
||||
stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun,
|
||||
launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem,
|
||||
} from "./runs.js";
|
||||
@@ -19,17 +19,13 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
||||
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
||||
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
||||
// The owner on the forge the mesh's repositories and packages are under, the operator's too (novox/hq ADR 0507
|
||||
// §9), read the same way.
|
||||
const ownerOf = (): string => (env.MESH_LAB_FORGE_OWNER ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_OWNER")).trim();
|
||||
const noOwner = "the lab's forge owner is not set: config for lab, {\"forge-owner\": \"<owner>\"}";
|
||||
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
|
||||
// The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call.
|
||||
const bindingOf = (): string | undefined =>
|
||||
env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined);
|
||||
const registryOf = async (forge: string, owner: string) => {
|
||||
const root = publicRootOf(bindingOf(), owner);
|
||||
const registry = await npmRegistry(forge, owner, fetch, root.root);
|
||||
const registryOf = async (forge: string) => {
|
||||
const root = publicRootOf(bindingOf());
|
||||
const registry = await npmRegistry(forge, fetch, root.root);
|
||||
return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry;
|
||||
};
|
||||
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
|
||||
@@ -43,8 +39,6 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
run: async () => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const owner = ownerOf();
|
||||
if (!owner) return { ok: false, output: noOwner };
|
||||
// What earlier checks left is cleared, and what could not be is said; this check works in a fresh
|
||||
// directory of its own either way, and removes it when it is done.
|
||||
const leftovers = clearChecks(work);
|
||||
@@ -52,13 +46,13 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
let check: ReturnType<typeof spawnSync>;
|
||||
let problems: string[];
|
||||
try {
|
||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", repositoryURL(forge, owner, "mesh-lab"), dir], { encoding: "utf8" });
|
||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
|
||||
if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") };
|
||||
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
|
||||
check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
|
||||
problems = setupProblems(forge, owner, `${dir}/setup`, credentialOf(forge));
|
||||
problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge));
|
||||
// And the address a run names the package registry by (npmRegistry): said when the registry does not say it.
|
||||
const registry = await registryOf(forge, owner);
|
||||
const registry = await registryOf(forge);
|
||||
if (registry.problem) problems.push(registry.problem);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -97,8 +91,6 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
run: async (args) => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const owner = ownerOf();
|
||||
if (!owner) return { started: false, reason: noOwner };
|
||||
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
|
||||
let refs: Record<string, string> = {};
|
||||
@@ -113,8 +105,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
|
||||
// **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698,
|
||||
// review 256): the registry asked first, then the check and the start with nothing awaited between.
|
||||
const launched = await launchRun(work, { registry: () => registryOf(forge, owner), base: () => readBaseImage() },
|
||||
(registry) => start(work, forge, owner, tests, refs, credentialOf(forge), registry));
|
||||
const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() },
|
||||
(registry) => start(work, forge, tests, refs, credentialOf(forge), registry));
|
||||
if (!launched.started) return launched;
|
||||
return { started: true, run: launched.job, base: describeBase(launched.base), registry: launched.registry,
|
||||
...(launched.problem ? { problem: launched.problem } : {}) };
|
||||
@@ -142,12 +134,10 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
if (args.rebuild !== true) return reading;
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { ...reading, started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const owner = ownerOf();
|
||||
if (!owner) return { ...reading, started: false, reason: noOwner };
|
||||
// Not under a run, nor beside another rebuild: the registry asked first, then the check and the start with
|
||||
// nothing awaited between (review 256).
|
||||
const launched = await launchRebuild(work, { registry: () => registryOf(forge, owner), base: () => before },
|
||||
(registry) => startBaseBuild(work, forge, owner, credentialOf(forge), registry));
|
||||
const launched = await launchRebuild(work, { registry: () => registryOf(forge), base: () => before },
|
||||
(registry) => startBaseBuild(work, forge, credentialOf(forge), registry));
|
||||
if (launched.started === false) return { ...reading, started: false, reason: launched.reason };
|
||||
return { ...reading, started: true, build: launched.job };
|
||||
},
|
||||
@@ -184,7 +174,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
|
||||
* would go through.
|
||||
*/
|
||||
export function setupProblems(forge: string, owner: string, dir: string, credential?: string): string[] {
|
||||
export function setupProblems(forge: string, dir: string, credential?: string): string[] {
|
||||
const problems: string[] = [];
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
// The credential a run would offer, in a file of its own outside the check's clones, removed after.
|
||||
@@ -196,7 +186,7 @@ export function setupProblems(forge: string, owner: string, dir: string, credent
|
||||
});
|
||||
try {
|
||||
for (const repo of REPOSITORIES) {
|
||||
const url = repositoryURL(forge, owner, repo);
|
||||
const url = `${forge}/novox/${repo}.git`;
|
||||
const remote = git(["ls-remote", "--heads", url, "main"]);
|
||||
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
|
||||
const said = remote.stderr.trim().split("\n")[0] ?? "";
|
||||
|
||||
+13
-22
@@ -60,11 +60,8 @@ export function installStep(root: string, build: boolean): string {
|
||||
fi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the forge serves the mesh's npm packages, under its own address: gitea's npm registry for the owner the
|
||||
* mesh's repositories are under, the operator's config value (novox/hq ADR 0507 §9).
|
||||
*/
|
||||
export const npmPath = (owner: string): string => `/api/packages/${encodeURIComponent(owner)}/npm/`;
|
||||
/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */
|
||||
export const NPM_PATH = "/api/packages/novox/npm/";
|
||||
|
||||
/**
|
||||
* The registry's public root, as the forge serves it in the lab's npm-package-registry binding
|
||||
@@ -73,8 +70,7 @@ export const npmPath = (owner: string): string => `/api/packages/${encodeURIComp
|
||||
* nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a
|
||||
* machine with no public domain serves none (ADR 0346 §3).
|
||||
*/
|
||||
export function publicRootOf(bindingFile: string | undefined, owner: string): { root?: string; problem?: string } {
|
||||
const NPM_PATH = npmPath(owner);
|
||||
export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } {
|
||||
if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" };
|
||||
let said: unknown;
|
||||
try {
|
||||
@@ -116,9 +112,8 @@ export function publicRootOf(bindingFile: string | undefined, owner: string): {
|
||||
* reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the
|
||||
* forge's own address and says why, so a run still starts and lab_check names it.
|
||||
*/
|
||||
export async function npmRegistry(forge: string, owner: string, get: typeof fetch = fetch, publicRoot?: string):
|
||||
export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string):
|
||||
Promise<{ registry: string; problem?: string }> {
|
||||
const NPM_PATH = npmPath(owner);
|
||||
const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`;
|
||||
const url = `${asked}@novox%2fmesh-sdk`;
|
||||
const fallback = (why: string) => ({
|
||||
@@ -458,10 +453,6 @@ export function running(work: string): RunStatus | undefined {
|
||||
return listRuns(work).find((s) => !["passed", "failed", "stopped", "lost"].includes(s.state));
|
||||
}
|
||||
|
||||
/** A repository's clone address on the forge: under the owner the mesh's repositories are under (ADR 0507 §9). */
|
||||
export const repositoryURL = (forge: string, owner: string, repo: string): string =>
|
||||
`${forge}/${encodeURIComponent(owner)}/${repo}.git`;
|
||||
|
||||
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
/**
|
||||
@@ -470,8 +461,8 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
|
||||
* at this run's own tree, so what is built and claimed is exactly what was checked out.
|
||||
*/
|
||||
export function script(work: string, id: string, forge: string, owner: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry = `${forge}${npmPath(owner)}`): string {
|
||||
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
||||
const dir = runDir(work, id);
|
||||
const setState = (state: string) =>
|
||||
`node -e ${shellQuote(
|
||||
@@ -480,7 +471,7 @@ export function script(work: string, id: string, forge: string, owner: string, t
|
||||
const clones = REPOSITORIES.map((repo) => {
|
||||
const ref = refs[repo] ?? "main";
|
||||
return [
|
||||
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(repositoryURL(forge, owner, repo))} ${shellQuote(join(dir, repo))}`,
|
||||
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
|
||||
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
@@ -524,7 +515,7 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
|
||||
}
|
||||
|
||||
/** start begins a run and returns at once with its status. */
|
||||
export function start(work: string, forge: string, owner: string, tests: string[], refs: Record<string, string>,
|
||||
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry?: string): RunStatus {
|
||||
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
const dir = runDir(work, id);
|
||||
@@ -534,7 +525,7 @@ export function start(work: string, forge: string, owner: string, tests: string[
|
||||
// What a crashed or rebooted run left behind goes before this run writes its own.
|
||||
cleanCredentials(work);
|
||||
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, owner, tests, refs, credentials, registry), { mode: 0o700 });
|
||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 });
|
||||
|
||||
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
|
||||
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
|
||||
@@ -724,13 +715,13 @@ export function baseBuildLog(work: string, id: string, lines = 40): string {
|
||||
* baseScript is what one rebuild runs: mesh-lab's main from the forge, installed the way a run installs it,
|
||||
* then its `base build`, which replaces the image and reads back that it works before publishing it.
|
||||
*/
|
||||
export function baseScript(dir: string, forge: string, owner: string, credential?: string, registry = `${forge}${npmPath(owner)}`): string {
|
||||
export function baseScript(dir: string, forge: string, credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
||||
const lab = join(dir, "mesh-lab");
|
||||
return `set -euo pipefail
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
${credentialGuard(credential)}
|
||||
cd ${shellQuote(dir)}
|
||||
git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(repositoryURL(forge, owner, "mesh-lab"))} ${shellQuote(lab)}
|
||||
git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)}
|
||||
${credential ? `rm -f ${shellQuote(credential)}` : ""}
|
||||
echo "mesh-lab $(git -C ${shellQuote(lab)} rev-parse HEAD)"
|
||||
printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
|
||||
@@ -742,7 +733,7 @@ node --experimental-strip-types src/cli.ts base build
|
||||
}
|
||||
|
||||
/** startBaseBuild rebuilds the base image in the background and answers at once with the build. */
|
||||
export function startBaseBuild(work: string, forge: string, owner: string, credential?: string, registry?: string): BaseBuild {
|
||||
export function startBaseBuild(work: string, forge: string, credential?: string, registry?: string): BaseBuild {
|
||||
const id = `base-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
const dir = join(work, id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -751,7 +742,7 @@ export function startBaseBuild(work: string, forge: string, owner: string, crede
|
||||
// What a crashed or rebooted run or rebuild left behind goes before this one writes its own.
|
||||
cleanCredentials(work);
|
||||
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||
writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, owner, credentials, registry), { mode: 0o700 });
|
||||
writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, credentials, registry), { mode: 0o700 });
|
||||
const wrapper = `bash ${shellQuote(join(dir, "build.sh"))} > ${shellQuote(join(dir, "build.log"))} 2>&1; code=$?
|
||||
node -e ${shellQuote(
|
||||
`const f=${JSON.stringify(buildStatusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=process.argv[1]==="0"?"built":"failed";s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||
|
||||
@@ -82,7 +82,7 @@ Steps 1, 2 and 3 are harmless in any order. Step 4 waits for `i3`.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | node-b | node-d |
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | nothing until step 1 (`lemurs-git` reads as installed) | the same |
|
||||
| `/etc/lemurs/config.toml` | the found file kept once, then the module's written | the same |
|
||||
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The environment mailu gives automx on node-c when the fixtures were captured (files/mailu.env): the mail domain,
|
||||
// The environment mailu gives automx on novox when the fixtures were captured (files/mailu.env): the mail domain,
|
||||
// the route name for both servers, the ports.
|
||||
var capturedEnvironment = map[string]string{
|
||||
"AMX_MAIL_DOMAINS": "example.org",
|
||||
"AMX_IMAP_ADDRESS": "mail.example.org",
|
||||
"AMX_MAIL_DOMAINS": "novox.be",
|
||||
"AMX_IMAP_ADDRESS": "mail.novox.be",
|
||||
"AMX_IMAP_PORT": "143",
|
||||
"AMX_SMTP_ADDRESS": "mail.example.org",
|
||||
"AMX_SMTP_ADDRESS": "mail.novox.be",
|
||||
"AMX_SMTP_PORT": "587",
|
||||
"AUTOMX_PORT": "4243",
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func TestTheOnlyCorrectedAnswerIsTheImapServersName(t *testing.T) {
|
||||
}
|
||||
for _, c := range corrected {
|
||||
orig := readFile(t, strings.TrimSuffix(c, ".corrected.body")+".body")
|
||||
want := strings.Replace(orig, "<hostname />", "<hostname>mail.example.org</hostname>", 1)
|
||||
want := strings.Replace(orig, "<hostname />", "<hostname>mail.novox.be</hostname>", 1)
|
||||
if strings.Contains(want, "<hostname />") || want == orig {
|
||||
t.Fatalf("%s: the capture has no single empty hostname to correct", c)
|
||||
}
|
||||
@@ -198,12 +198,12 @@ func TestTheImapServerIsNamedFromTheImapAddress(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
newHandler(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@example.org", nil))
|
||||
newHandler(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@novox.be", nil))
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `<incomingServer type="imap"><hostname>imap.example.test</hostname><port>993</port>`) {
|
||||
t.Errorf("incoming server not named from AMX_IMAP_ADDRESS and AMX_IMAP_PORT: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<outgoingServer type="smtp"><hostname>mail.example.org</hostname><port>587</port>`) {
|
||||
if !strings.Contains(body, `<outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port>`) {
|
||||
t.Errorf("outgoing server changed: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -213,13 +213,13 @@ func TestEveryDomainOfTheEnvironmentIsServed(t *testing.T) {
|
||||
for k, v := range capturedEnvironment {
|
||||
env[k] = v
|
||||
}
|
||||
env["AMX_MAIL_DOMAINS"] = "example.org, example.test"
|
||||
env["AMX_MAIL_DOMAINS"] = "novox.be, example.test"
|
||||
cfg, err := configFrom(getenvOf(env))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := newHandler(cfg)
|
||||
for _, d := range []string{"example.org", "example.test"} {
|
||||
for _, d := range []string{"novox.be", "example.test"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@"+d, nil))
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<domain>"+d+"</domain>") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user