Compare commits
226
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
170fad2e93 | ||
|
|
fab57411c5 | ||
|
|
212c8e4f52 | ||
|
|
7220cbc787 | ||
|
|
4713d984ea | ||
|
|
ceb7d920b6 | ||
|
|
9d5ddcd050 | ||
|
|
007dd17910 | ||
|
|
30e6435dea | ||
|
|
ba3635307c | ||
|
|
c88b75a822 | ||
|
|
bd5bf861f1 | ||
|
|
c55e705357 | ||
|
|
d4e9f699d1 | ||
|
|
c09b7c0513 | ||
|
|
0a398579e2 | ||
|
|
219c6b1a47 | ||
|
|
649865b7d2 | ||
|
|
098a0f6542 | ||
|
|
4b50f5171b | ||
|
|
a8e62b52fd | ||
|
|
653eb10d41 | ||
|
|
afc919e47e | ||
|
|
8d92433f20 | ||
|
|
3209cfa6fb | ||
|
|
4d017422dc | ||
|
|
5c138c693f | ||
|
|
04a85108e5 | ||
|
|
389630265b | ||
|
|
4847c96b58 | ||
|
|
161abbc621 | ||
|
|
de31c6a9d1 | ||
|
|
4909c9c8c7 | ||
|
|
171148a59f | ||
|
|
1db1965073 | ||
|
|
120c38427c | ||
|
|
e7112ad575 | ||
|
|
1758a2cb4e | ||
|
|
87144173a0 | ||
|
|
14a5c1c80a | ||
|
|
1267c74fec | ||
|
|
f7971f0f4b | ||
|
|
dd074ecb18 | ||
|
|
228b8669d0 | ||
|
|
96b7b6551c | ||
|
|
7c4891cd13 | ||
|
|
dfb9336ced | ||
|
|
6a0e08bec1 | ||
|
|
7ad9e531d4 | ||
|
|
5db896c813 | ||
|
|
e54bca6575 | ||
|
|
dbe28dceba | ||
|
|
39d724fd77 | ||
|
|
3b89d398fa | ||
|
|
0c98e627c2 | ||
|
|
d33e6baa73 | ||
|
|
e7f1a99188 | ||
|
|
0bdbdd8772 | ||
|
|
2f05e70734 | ||
|
|
b5f812270e | ||
|
|
3cce15fa81 | ||
|
|
86ba54b06e | ||
|
|
af91a7e660 | ||
|
|
0270027bf9 | ||
|
|
835bf22b7d | ||
|
|
76a0a726f0 | ||
|
|
df821c8a4a | ||
|
|
f40269caec | ||
|
|
2149d8f3e7 | ||
|
|
e65c8c8961 | ||
|
|
ea8e0def6e | ||
|
|
f8ac498d64 | ||
|
|
276422a85b | ||
|
|
28ef6e9e27 | ||
|
|
49caf17ff5 | ||
|
|
cac09a4e20 | ||
|
|
29b4c02709 | ||
|
|
c3d3194bc8 | ||
|
|
d0463fb510 | ||
|
|
6bf054b982 | ||
|
|
fbbf28d21c | ||
|
|
fb89990e2b | ||
|
|
d0a3784be2 | ||
|
|
555ed4963a | ||
|
|
edb6d3b7d3 | ||
|
|
8928e6f972 | ||
|
|
2d8c8f6d75 | ||
|
|
9194e15072 | ||
|
|
77578a882f | ||
|
|
3f9081d00e | ||
|
|
7842001c53 | ||
|
|
09e749bc1a | ||
|
|
f339d0aab7 | ||
|
|
43fccb843c | ||
|
|
f550f1c6b2 | ||
|
|
71f47c3291 | ||
|
|
dc703d1511 | ||
|
|
d70135bf08 | ||
|
|
ea8692b08f | ||
|
|
ff0f43ea2f | ||
|
|
2b2517e4a5 | ||
|
|
b7a4a1a6b0 | ||
|
|
c1a9ad2abc | ||
|
|
c93f77ac19 | ||
|
|
75f3f52589 | ||
|
|
900a2da3e3 | ||
|
|
b7f3664839 | ||
|
|
9511074f40 | ||
|
|
9651a8b2da | ||
|
|
3cf75580b5 | ||
|
|
32c66a98c1 | ||
|
|
62437f8964 | ||
|
|
ac4d156cb7 | ||
|
|
95e69276c3 | ||
|
|
56b285d3e8 | ||
|
|
5cc401683c | ||
|
|
79ce72a7b7 | ||
|
|
a22fdd015b | ||
|
|
f40fa6e8e0 | ||
|
|
b015576a13 | ||
|
|
e4fb4cc793 | ||
|
|
1de9a903c8 | ||
|
|
0bb6584437 | ||
|
|
c0bc0d4826 | ||
|
|
efac1b4856 | ||
|
|
bc77afa6bb | ||
|
|
e4373768af | ||
|
|
2cb2e27cdf | ||
|
|
0f4e631cdd | ||
|
|
dcf6e86d02 | ||
|
|
98ca97fc5b | ||
|
|
802c72e4ed | ||
|
|
eb717cc514 | ||
|
|
db67a0e55e | ||
|
|
34277ef60f | ||
|
|
a3b51a9d9a | ||
|
|
e5608f7b00 | ||
|
|
5a18eb5f4a | ||
|
|
138a1e1c4b | ||
|
|
f1fbd91f0d | ||
|
|
5ddc3ff312 | ||
|
|
b5908b486e | ||
|
|
aa57bbcd44 | ||
|
|
67a585600d | ||
|
|
1caf728a4b | ||
|
|
02816c0f7a | ||
|
|
7bf63b0ac5 | ||
|
|
426db57d3f | ||
|
|
1d995d2bef | ||
|
|
92225cd528 | ||
|
|
35e4764d35 | ||
|
|
b7b9f2a3da | ||
|
|
68183ee441 | ||
|
|
cff5ed46b7 | ||
|
|
7451e7edf4 | ||
|
|
529c156f64 | ||
|
|
47df3a4d60 | ||
|
|
2d2e831659 | ||
|
|
d3ebaf72eb | ||
|
|
183b9680c1 | ||
|
|
74baa53787 | ||
|
|
332aa75afd | ||
|
|
9e2d1164f0 | ||
|
|
48b08c74ab | ||
|
|
260be1c3e6 | ||
|
|
224a7a66a5 | ||
|
|
5a6d082e8c | ||
|
|
7b5f7f84ac | ||
|
|
3508be1476 | ||
|
|
7c5d11e55d | ||
|
|
3dcaa7a996 | ||
|
|
848249cf54 | ||
|
|
692fc3e1ab | ||
|
|
ce8d52ed0b | ||
|
|
f5153a490c | ||
|
|
8815f950b0 | ||
|
|
c948f3a61c | ||
|
|
91877e3421 | ||
|
|
77b89ff182 | ||
|
|
e7ca330e1f | ||
|
|
b4e90e5fce | ||
|
|
e2220df5e8 | ||
|
|
0ec446ab75 | ||
|
|
3c0496d1d3 | ||
|
|
97a111e70a | ||
|
|
eb4f16431c | ||
|
|
c4fd31aa7e | ||
|
|
09d5360bce | ||
|
|
b4d7a32abf | ||
|
|
6aa4f135c9 | ||
|
|
4915f69857 | ||
|
|
843e408556 | ||
|
|
4ef2fa2dff | ||
|
|
8288042eb7 | ||
|
|
3bbae41037 | ||
|
|
77f3963d83 | ||
|
|
49a28f8537 | ||
|
|
c770f7947d | ||
|
|
4b8e7f11cf | ||
|
|
0d2686b5eb | ||
|
|
839be0eb64 | ||
|
|
dc144a4e7d | ||
|
|
bfc42e4f4f | ||
|
|
c4176107f2 | ||
|
|
a3794ea156 | ||
|
|
e8cb2ee470 | ||
|
|
07b3c5e77a | ||
|
|
f3100d1116 | ||
|
|
64a96a13f7 | ||
|
|
f1fb73e12c | ||
|
|
d2faada61b | ||
|
|
f436d1dace | ||
|
|
12ff1cfbc4 | ||
|
|
7bf0de0ff5 | ||
|
|
59b13d091d | ||
|
|
ce72a366bf | ||
|
|
d9876e5296 | ||
|
|
62e67578eb | ||
|
|
1319432e55 | ||
|
|
aa5d263c08 | ||
|
|
769a8d6f6a | ||
|
|
34b310b9f8 | ||
|
|
33d8b71cf7 | ||
|
|
aa0eaa6c52 | ||
|
|
d5804927b3 | ||
|
|
70500c21a8 |
@@ -27,3 +27,4 @@ modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-cli
|
|||||||
modules/brother/cmd/brother/brother
|
modules/brother/cmd/brother/brother
|
||||||
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||||
modules/mesh-config/mesh-config
|
modules/mesh-config/mesh-config
|
||||||
|
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/imagebuild
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Command imagebuild refuses any manifest but modules/buildah providing the rootless-image-build provision
|
||||||
|
// (novox/hq ADR 0487 §3, issue 1704): the image builder's tool is buildah alone, and docker, whose daemon holds
|
||||||
|
// the machine's root, never stands in for it. A second provider would let a node compose mesh-build-image over
|
||||||
|
// something that is not the rootless builder it was designed against.
|
||||||
|
//
|
||||||
|
// go run . <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provision is the provision only Provider may provide.
|
||||||
|
const Provision = "rootless-image-build"
|
||||||
|
|
||||||
|
// Provider is the one module allowed to provide it.
|
||||||
|
const Provider = "buildah"
|
||||||
|
|
||||||
|
// Finding is one manifest refused: a module other than buildah providing the provision.
|
||||||
|
type Finding struct {
|
||||||
|
Module string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reads every manifest under root/modules and answers which ones it refuses, and whether buildah provides.
|
||||||
|
func Check(root string) ([]Finding, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
var out []Finding
|
||||||
|
provided := false
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Provides []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"provides"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, p := range m.Provides {
|
||||||
|
if p.Name != Provision {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Module == Provider {
|
||||||
|
provided = true
|
||||||
|
} else {
|
||||||
|
out = append(out, Finding{Module: m.Module})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !provided {
|
||||||
|
out = append(out, Finding{Module: Provider})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := "."
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
root = os.Args[1]
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
for _, f := range found {
|
||||||
|
if f.Module == Provider {
|
||||||
|
fmt.Printf("%s does not provide %s (novox/hq ADR 0487 §3)\n", Provider, Provision)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("%s provides %s: only %s may, and docker never (novox/hq ADR 0487 §3)\n", f.Module, Provision, Provider)
|
||||||
|
}
|
||||||
|
if len(found) > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("imagebuild: rootless-image-build is provided only by buildah")
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func catalogue(t *testing.T, manifests map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, body := range manifests {
|
||||||
|
dir := filepath.Join(root, "modules", name)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildah = `{"module":"buildah","provides":[{"name":"rootless-image-build","reach":"machine"}]}`
|
||||||
|
|
||||||
|
func TestOnlyBuildahProvidesTheRootlessImageBuild(t *testing.T) {
|
||||||
|
root := catalogue(t, map[string]string{"buildah": buildah, "go": `{"module":"go","provides":[{"name":"go-toolchain"}]}`})
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("found %v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerAndAnySecondProviderAreRefused(t *testing.T) {
|
||||||
|
root := catalogue(t, map[string]string{
|
||||||
|
"buildah": buildah,
|
||||||
|
"docker": `{"module":"docker","provides":[{"name":"rootless-image-build"}]}`,
|
||||||
|
"other": `{"module":"other","provides":[{"name":"x"},{"name":"rootless-image-build"}]}`,
|
||||||
|
})
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []Finding{{Module: "docker"}, {Module: "other"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("found %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildahMustProvideIt(t *testing.T) {
|
||||||
|
root := catalogue(t, map[string]string{"buildah": `{"module":"buildah"}`})
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := []Finding{{Module: "buildah"}}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("found %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue as it stands: this is the check itself, run here too.
|
||||||
|
func TestTheCatalogueGivesTheProvisionToBuildahAlone(t *testing.T) {
|
||||||
|
got, err := Check("../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("the catalogue's providers of rootless-image-build are wrong: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/imageplace
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Command imageplace refuses an image artifact whose Dockerfile is not a file inside its own module's directory
|
||||||
|
// (novox/hq ADR 0487 §1, issue 1707): the image builder builds a module from that module's directory alone, so
|
||||||
|
// a `from` that leaves it (../other/Dockerfile) or names a file that is not there could never build.
|
||||||
|
//
|
||||||
|
// go run . <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding is one image refused: Module's artifact Artifact names From, and Why says what is wrong with it.
|
||||||
|
type Finding struct {
|
||||||
|
Module, Artifact, From, Why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reads every manifest under root/modules and answers which images it refuses.
|
||||||
|
func Check(root string) ([]Finding, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
var out []Finding
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
From string `json:"from"`
|
||||||
|
} `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
if a.Kind != "image" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f := Finding{Module: m.Module, Artifact: a.Name, From: a.From}
|
||||||
|
clean := filepath.Clean(a.From)
|
||||||
|
switch {
|
||||||
|
case a.From == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)):
|
||||||
|
f.Why = "is not a path inside the module's own directory"
|
||||||
|
default:
|
||||||
|
info, err := os.Stat(filepath.Join(dir, clean))
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
f.Why = "is not a file in the module's directory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Why != "" {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := "."
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
root = os.Args[1]
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
for _, f := range found {
|
||||||
|
fmt.Printf("%s: image %s builds from %q, which %s (novox/hq ADR 0487 §1)\n", f.Module, f.Artifact, f.From, f.Why)
|
||||||
|
}
|
||||||
|
if len(found) > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("imageplace: every image builds from a Dockerfile in its own module's directory")
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// module writes modules/<name>/module.json with one image artifact from `from`, and the files named.
|
||||||
|
func module(t *testing.T, root, name, from string, files ...string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, "modules", name)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := `{"module":"` + name + `","build":{"artifacts":[{"name":"server","kind":"image","from":"` + from + `"},{"name":"files","kind":"archive","from":"../elsewhere"}]}}`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
p := filepath.Join(dir, f)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte("FROM scratch\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADockerfileInTheModulesOwnDirectoryPasses(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "own", "Dockerfile", "Dockerfile")
|
||||||
|
module(t, root, "nested", "image/Dockerfile", "image/Dockerfile")
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("found %v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADockerfileOutsideTheModulesDirectoryFails(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "other", "Dockerfile", "Dockerfile")
|
||||||
|
module(t, root, "leaves", "../other/Dockerfile")
|
||||||
|
module(t, root, "absolute", "/etc/Dockerfile")
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var mods []string
|
||||||
|
for _, f := range got {
|
||||||
|
mods = append(mods, f.Module)
|
||||||
|
}
|
||||||
|
if want := []string{"absolute", "leaves"}; !reflect.DeepEqual(mods, want) {
|
||||||
|
t.Errorf("refused %v (%v), want %v", mods, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMissingDockerfileFails(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "gone", "Dockerfile")
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Module != "gone" {
|
||||||
|
t.Errorf("found %v, want gone refused", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue as it stands: this is the check itself, run here too.
|
||||||
|
func TestTheCataloguesImagesBuildFromTheirOwnDirectories(t *testing.T) {
|
||||||
|
got, err := Check("../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("an image builds from outside its module: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/particulars
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// go run . <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 naming file:line and the word, 2 when a list cannot be read.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"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-])`)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := "."
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
root = os.Args[1]
|
||||||
|
}
|
||||||
|
os.Exit(check(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if err == nil && len(names) == 0 {
|
||||||
|
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
|
||||||
|
}
|
||||||
|
var modules []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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
for _, f := range findings {
|
||||||
|
fmt.Println(f)
|
||||||
|
}
|
||||||
|
if len(findings) > 0 {
|
||||||
|
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))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// readList reads a list: one entry a line; blank lines and lines starting with # are not entries.
|
||||||
|
func readList(path string) ([]string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []string
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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])
|
||||||
|
dir := filepath.Join(root, "modules", m)
|
||||||
|
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
switch d.Name() {
|
||||||
|
case "node_modules", "dist", "vendor", ".git":
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
out = append(out, fmt.Sprintf("%s:0: node name %q in the path", filepath.ToSlash(rel), n))
|
||||||
|
}
|
||||||
|
if d.Name() == "go.sum" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bytes.IndexByte(b, 0) >= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, scan(filepath.ToSlash(rel), blankWith(hardware, string(b)), names)...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scan returns a finding for each node name in src, whole-word and in any case.
|
||||||
|
func scan(file, src string, names []string) []string {
|
||||||
|
var out []string
|
||||||
|
for i, line := range strings.Split(src, "\n") {
|
||||||
|
for _, n := range hits(line, names) {
|
||||||
|
out = append(out, fmt.Sprintf("%s:%d: node name %q — %s", file, i+1, n, strings.Join(strings.Fields(line), " ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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)
|
||||||
|
var out []string
|
||||||
|
for _, n := range names {
|
||||||
|
if matcher(n).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
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var names = []string{"novox", "ace", "shanks", "g14"}
|
||||||
|
|
||||||
|
func TestANodeNameIsRefusedWhereItIsWritten(t *testing.T) {
|
||||||
|
cases := map[string]struct{ file, src string }{
|
||||||
|
"manifest string": {"modules/kernel/module.json", "{\n \"description\": \"as on Shanks\"\n}\n"},
|
||||||
|
"file line": {"modules/kernel/files/boot.sh", "#!/bin/sh\necho run on g14\n"},
|
||||||
|
"go identifier": {"modules/kernel/cmd/x/a.go", "package main\n\nfunc novox() {}\n"},
|
||||||
|
"test name": {"modules/kernel/cmd/x/a_test.go", "package main\n\nfunc TestAce(t *testing.T) {}\n\nfunc TestOnAce_x(t *testing.T) {}\n"},
|
||||||
|
}
|
||||||
|
for name, c := range cases {
|
||||||
|
if got := scan(c.file, c.src, names); len(got) == 0 {
|
||||||
|
t.Errorf("%s: %q not refused", name, c.src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheFindingNamesTheLineAndTheWord(t *testing.T) {
|
||||||
|
got := scan("f.sh", "a\nb\nrun on ACE now\n", names)
|
||||||
|
if len(got) != 1 || !strings.HasPrefix(got[0], "f.sh:3:") || !strings.Contains(got[0], "ace") {
|
||||||
|
t.Errorf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheForgeIsNotANodeName(t *testing.T) {
|
||||||
|
for _, s := range []string{
|
||||||
|
"see novox/hq issue 5",
|
||||||
|
"module git.novox.be/novox/mesh-sdk/go v1.2.3",
|
||||||
|
"https://git.novox.be/novox/mesh-catalog",
|
||||||
|
"space trace surface grace g14x",
|
||||||
|
} {
|
||||||
|
if got := scan("f", s, names); len(got) != 0 {
|
||||||
|
t.Errorf("%q refused: %v", s, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANodeNameInAPathOrJoinedIsRefused(t *testing.T) {
|
||||||
|
for _, s := range []string{
|
||||||
|
"modules/kernel/testdata/novox/meminfo",
|
||||||
|
"/srv/novox/data",
|
||||||
|
"testdata/shanks-meminfo.txt",
|
||||||
|
"on-shanks",
|
||||||
|
"shanks-desktop",
|
||||||
|
"mesh-novox",
|
||||||
|
"func novoxTree() {}",
|
||||||
|
"aceFiles := 1",
|
||||||
|
"g14Meminfo := 2",
|
||||||
|
"novox_tree := 3",
|
||||||
|
} {
|
||||||
|
if got := scan("f", s, names); len(got) == 0 {
|
||||||
|
t.Errorf("%q not refused", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
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)
|
||||||
|
if err != nil || len(got) != 1 || !strings.HasPrefix(got[0], "modules/kernel/testdata/ace/x:0: ") {
|
||||||
|
t.Errorf("got %v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(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\n")
|
||||||
|
write("modules/kernel/a", "clean\n")
|
||||||
|
if got := check(root); got != 0 {
|
||||||
|
t.Errorf("clean = %d, want 0", got)
|
||||||
|
}
|
||||||
|
write("modules/kernel/a", "on ace\n")
|
||||||
|
if got := check(root); got != 1 {
|
||||||
|
t.Errorf("finding = %d, want 1", got)
|
||||||
|
}
|
||||||
|
os.Remove(filepath.Join(root, "particulars"))
|
||||||
|
if got := check(root); got != 2 {
|
||||||
|
t.Errorf("no list = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReadsOnlyTheModulesItIsGiven(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("modules/kernel/files/a", "on novox\n")
|
||||||
|
write("modules/other/files/a", "on novox\n")
|
||||||
|
got, err := run(root, names, []string{"kernel"}, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0], "modules/kernel/files/a:1") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Command toolchains refuses any manifest but the owner's declaring a language's toolchain package (novox/hq
|
// Command toolchains refuses any manifest but the owner's declaring a language's toolchain package (novox/hq
|
||||||
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, and nodejs and npm only in modules/nodejs.
|
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, nodejs and npm only in modules/nodejs, and buildah (issue 1702) only in modules/buildah.
|
||||||
// Everything that compiles or runs that language requires the owner's provision instead (go-toolchain,
|
// Everything that compiles or runs that language requires the owner's provision instead (go-toolchain,
|
||||||
// node-toolchain), so the package is declared once, and a node without the owner refuses the module that
|
// node-toolchain), so the package is declared once, and a node without the owner refuses the module that
|
||||||
// needs it at resolution rather than failing at its first build. The owners must declare their packages.
|
// needs it at resolution rather than failing at its first build. The owners must declare their packages.
|
||||||
@@ -19,9 +19,10 @@ import (
|
|||||||
|
|
||||||
// Owners names, for each toolchain package, the one module that declares it.
|
// Owners names, for each toolchain package, the one module that declares it.
|
||||||
var Owners = map[string]string{
|
var Owners = map[string]string{
|
||||||
"go": "go",
|
"buildah": "buildah",
|
||||||
"nodejs": "nodejs",
|
"go": "go",
|
||||||
"npm": "nodejs",
|
"nodejs": "nodejs",
|
||||||
|
"npm": "nodejs",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
|
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
|
||||||
@@ -100,5 +101,5 @@ func main() {
|
|||||||
if len(found) > 0 {
|
if len(found) > 0 {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Println("toolchains: go, nodejs and npm are declared only by their owners")
|
fmt.Println("toolchains: go, nodejs, npm and buildah are declared only by their owners")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
|
|||||||
root := catalogue(t, map[string]string{
|
root := catalogue(t, map[string]string{
|
||||||
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
|
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
|
||||||
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
|
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
|
||||||
|
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
|
||||||
"lab": `{"module":"lab","resources":[{"id":"git","type":"package","package":"git"},{"id":"go","type":"package","package":"go"}]}`,
|
"lab": `{"module":"lab","resources":[{"id":"git","type":"package","package":"git"},{"id":"go","type":"package","package":"go"}]}`,
|
||||||
"builder": `{"module":"builder","resources":[{"id":"n","type":"package","package":"nodejs"},{"id":"dir","type":"directory","path":"/go"}]}`,
|
"builder": `{"module":"builder","resources":[{"id":"n","type":"package","package":"nodejs"},{"id":"dir","type":"directory","path":"/go"}]}`,
|
||||||
})
|
})
|
||||||
@@ -39,6 +40,23 @@ func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestASecondDeclarerOfBuildahIsRefused(t *testing.T) {
|
||||||
|
root := catalogue(t, map[string]string{
|
||||||
|
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
|
||||||
|
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
|
||||||
|
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
|
||||||
|
"imagery": `{"module":"imagery","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
|
||||||
|
})
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []Finding{{Module: "imagery", Package: "buildah", Owner: "buildah"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("found %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
|
func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
|
||||||
root := catalogue(t, map[string]string{
|
root := catalogue(t, map[string]string{
|
||||||
"go": `{"module":"go","resources":[]}`,
|
"go": `{"module":"go","resources":[]}`,
|
||||||
@@ -47,8 +65,8 @@ func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(got) != 3 {
|
if len(got) != 4 {
|
||||||
t.Errorf("an owner missing its packages gave %v, want three findings (go, nodejs, npm)", got)
|
t.Errorf("an owner missing its packages gave %v, want four findings (buildah, go, nodejs, npm)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
59
|
58
|
||||||
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
||||||
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
||||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||||
|
|||||||
@@ -50,11 +50,20 @@
|
|||||||
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
|
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
|
||||||
# only under a home; and no module declares a path inside a directory of another module.
|
# only under a home; and no module declares a path inside a directory of another module.
|
||||||
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
|
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
|
||||||
|
# 12. the image builder (novox/hq ADR 0487 §3, issue 1704): only modules/buildah provides rootless-image-build.
|
||||||
|
# 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
(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`.
|
||||||
|
(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.
|
# 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 . ../..)
|
(cd checks/toolchains && go test -count=1 . && go run . ../..)
|
||||||
|
# The image builder (novox/hq ADR 0487 §3): only modules/buildah provides rootless-image-build, docker never.
|
||||||
|
(cd checks/imagebuild && go test -count=1 . && go run . ../..)
|
||||||
|
# The image's place (novox/hq ADR 0487 §1): every image builds from a Dockerfile in its own module's directory.
|
||||||
|
(cd checks/imageplace && go test -count=1 . && go run . ../..)
|
||||||
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
||||||
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
||||||
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
|
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
|
||||||
@@ -190,6 +199,19 @@ for m in $touched; do
|
|||||||
# another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07).
|
# another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07).
|
||||||
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... &&
|
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... &&
|
||||||
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
|
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
|
||||||
|
# The docker tests of the bus's snapshot program and entrypoint (novox/hq ADR 0480 clause 6) start throwaway nats
|
||||||
|
# containers, and the Go toolchain container holds no docker, so above they skip with a "NOT RUN" line. Where the
|
||||||
|
# build seat says docker exists (MESH_CHECK_DOCKER=1) they run here by name, and a missing docker fails the check
|
||||||
|
# (MESH_REQUIRE_DOCKER=1 makes the tests fail instead of skip). Without it the step says it did not run.
|
||||||
|
if [ "$m" = nats ]; then
|
||||||
|
if [ "${MESH_CHECK_DOCKER:-}" = 1 ]; then
|
||||||
|
(cd modules/nats && MESH_REQUIRE_DOCKER=1 CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} -count=1 \
|
||||||
|
-run 'TestTheDeclaredDumpRunsInTheBussImage|TestTheEntrypointReloadsTheServerWithoutDroppingAConnection|TestTheStoreSurvivesTheNewDeclarationTheOldAndTheNewAgain|TestASnapshotOfALiveBusRestoresToTheSameContent|TestTheSnapshotUserCannotChangeTheBus' \
|
||||||
|
./cmd/mesh-nats-snapshot) || { echo "the bus's docker tests failed"; exit 1; }
|
||||||
|
else
|
||||||
|
echo "NOT RUN: the bus's five docker tests (modules/nats/cmd/mesh-nats-snapshot) skipped: this check has no docker (MESH_CHECK_DOCKER is not 1)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
if [ -n "$untestable" ]; then
|
if [ -n "$untestable" ]; then
|
||||||
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
|
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ login.
|
|||||||
|
|
||||||
## What changes when it is assigned
|
## What changes when it is assigned
|
||||||
|
|
||||||
| | g14 | shanks |
|
| | node-b | node-d |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| packages | none (all present) | the same |
|
| 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 |
|
| 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,7 +94,8 @@ func TestTheManifestNamesNoMachineAndItsTriggersExist(t *testing.T) {
|
|||||||
ids[r["id"].(string)] = true
|
ids[r["id"].(string)] = true
|
||||||
}
|
}
|
||||||
raw, _ := os.ReadFile("../../module.json")
|
raw, _ := os.ReadFile("../../module.json")
|
||||||
for _, banned := range []string{"jochen", "/home/", "/run/user/1000", "\"g14\"", "shanks"} {
|
// 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"} {
|
||||||
if strings.Contains(string(raw), banned) {
|
if strings.Contains(string(raw), banned) {
|
||||||
t.Errorf("the manifest says %q", banned)
|
t.Errorf("the manifest says %q", banned)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# betterbird
|
# betterbird
|
||||||
|
|
||||||
Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's
|
Betterbird, the mail and calendar client of node-b and node-d (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,
|
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
|
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
|
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
|
**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
|
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
|
`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 (g14 runs 1.5.7) is said as a
|
installs the pinned one where Owl is missing. A newer Owl in the profile (node-b 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
|
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.
|
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
|
## What changes when it is pushed
|
||||||
|
|
||||||
The module is assigned to shanks today (look-only, from issue 345's first part); g14 runs Betterbird
|
The module is assigned to node-d today (look-only, from issue 345's first part); node-b runs Betterbird
|
||||||
outside the mesh. With this version, a push to a node (each waits for the operator: `upgrade: record`):
|
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 (g14, shanks) |
|
| | on a node with the community package (node-b, node-d) |
|
||||||
|---|---|
|
|---|---|
|
||||||
| the package | `betterbird-bin` removed (quit Betterbird first) |
|
| 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 |
|
| `/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")
|
f.write(profileDir+"/prefs.js", `user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("mail.spam.manualMark", true);`+"\n")
|
||||||
oldNode, oldAsk := nodeName, asker
|
oldNode, oldAsk := nodeName, asker
|
||||||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||||||
nodeName = func() string { return "shanks" }
|
nodeName = func() string { return "node-d" }
|
||||||
var asked []map[string]any
|
var asked []map[string]any
|
||||||
asker = func(v string, a any) (json.RawMessage, error) {
|
asker = func(v string, a any) (json.RawMessage, error) {
|
||||||
args := a.(map[string]any)
|
args := a.(map[string]any)
|
||||||
asked = append(asked, args)
|
asked = append(asked, args)
|
||||||
if args["propose"] == nil {
|
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 shanks, 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 node-d, every value"}`), nil
|
||||||
}
|
}
|
||||||
return json.RawMessage(`{"ok": true, "output": "proposed: p-1"}`), nil
|
return json.RawMessage(`{"ok": true, "output": "proposed: p-1"}`), nil
|
||||||
}
|
}
|
||||||
@@ -427,7 +427,7 @@ func TestKeepProposesANodeLayerThroughTheController(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(asked) != 2 || asked[0]["node"] != "shanks" || asked[1]["node"] != "shanks" || asked[1]["propose"] != "true" ||
|
if len(asked) != 2 || asked[0]["node"] != "node-d" || asked[1]["node"] != "node-d" || asked[1]["propose"] != "true" ||
|
||||||
asked[1]["module"] != "betterbird" {
|
asked[1]["module"] != "betterbird" {
|
||||||
t.Fatalf("the node's layer read, then proposed: %v", asked)
|
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
|
// 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.
|
// time, carrying the layer as it stands.
|
||||||
|
|
||||||
// derivingMesh is two desktop nodes, g14 (this one, f) and shanks (f2), and a server that holds no Betterbird, as the
|
// derivingMesh is two desktop nodes, node-b (this one, f) and node-d (f2), and a server that holds no Betterbird, as the
|
||||||
// controller answers them; layers are the controller's layers, open the proposals that wait.
|
// controller answers them; layers are the controller's layers, open the proposals that wait.
|
||||||
type derivingMesh struct {
|
type derivingMesh struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
shanks *fake
|
nodeD *fake
|
||||||
layers map[string]map[string]any
|
layers map[string]map[string]any
|
||||||
open bool
|
open bool
|
||||||
proposed []map[string]any
|
proposed []map[string]any
|
||||||
@@ -35,17 +35,17 @@ func (d *derivingMesh) ask(key string, body any) (json.RawMessage, error) {
|
|||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case key == "seat:mesh-controller.nodes":
|
case key == "seat:mesh-controller.nodes":
|
||||||
return answer(true, `[{"name": "g14"}, {"name": "novox"}, {"name": "shanks"}]`)
|
return answer(true, `[{"name": "node-b"}, {"name": "node-c"}, {"name": "node-d"}]`)
|
||||||
case key == "seat:mesh-controller.node":
|
case key == "seat:mesh-controller.node":
|
||||||
if args["node"] == "novox" {
|
if args["node"] == "node-c" {
|
||||||
return answer(true, "novox\n assigned: postgres, nats\n")
|
return answer(true, "node-c\n assigned: postgres, nats\n")
|
||||||
}
|
}
|
||||||
return answer(true, fmt.Sprintf("%s\n last heard from here\n assigned: adwaita, betterbird, dunst\n", args["node"]))
|
return answer(true, fmt.Sprintf("%s\n last heard from here\n assigned: adwaita, betterbird, dunst\n", args["node"]))
|
||||||
case key == "betterbird.betterbird_derive@shanks":
|
case key == "betterbird.betterbird_derive@node-d":
|
||||||
if d.asleep {
|
if d.asleep {
|
||||||
return nil, errors.New("nats: no responders available for request")
|
return nil, errors.New("nats: no responders available for request")
|
||||||
}
|
}
|
||||||
here, err := d.shanks.deriveHere(stringsOf(args["kinds"]))
|
here, err := d.nodeD.deriveHere(stringsOf(args["kinds"]))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.t.Fatal(err)
|
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)}}})
|
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":
|
case key == "seat:mesh-controller.settings" && args["proposals"] == "true":
|
||||||
if d.open {
|
if d.open {
|
||||||
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"+
|
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 shanks (sources)\n by g14.node-tools at 2026-10-11 00:05; fingerprint dd0b\n chosen: done\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, "scd34 set mounts on shanks (sources)\n by g14.node-tools; 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")
|
||||||
case key == "seat:mesh-controller.settings" && args["propose"] == "true":
|
case key == "seat:mesh-controller.settings" && args["propose"] == "true":
|
||||||
d.proposed = append(d.proposed, args)
|
d.proposed = append(d.proposed, args)
|
||||||
return answer(true, "proposal sab12: Set the settings of betterbird?\n fingerprint ab12")
|
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 := newFake(t)
|
||||||
f2.profileWithAccounts()
|
f2.profileWithAccounts()
|
||||||
f2.profileWithConfiguration()
|
f2.profileWithConfiguration()
|
||||||
// shanks holds the same accounts and calendars, another signature, another tag colour and a calendar of its own.
|
// node-d 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"))+
|
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.type", "caldav");`+"\n"+
|
||||||
`user_pref("calendar.registry.s1.uri", "https://dav.example.org/cal/standup/");`+"\n"+
|
`user_pref("calendar.registry.s1.uri", "https://dav.example.org/cal/standup/");`+"\n"+
|
||||||
`user_pref("calendar.registry.s1.name", "Standup");`+"\n")
|
`user_pref("calendar.registry.s1.name", "Standup");`+"\n")
|
||||||
f2.declareAccounts(adoptable)
|
f2.declareAccounts(adoptable)
|
||||||
mesh := &derivingMesh{t: t, shanks: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}}
|
mesh := &derivingMesh{t: t, nodeD: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}}
|
||||||
oldNode, oldAsk := nodeName, asker
|
oldNode, oldAsk := nodeName, asker
|
||||||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||||||
nodeName = func() string { return "g14" }
|
nodeName = func() string { return "node-b" }
|
||||||
asker = mesh.ask
|
asker = mesh.ask
|
||||||
|
|
||||||
a, err := f.Derive(nil, false, false, nil, "")
|
a, err := f.Derive(nil, false, false, nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(a.From, []string{"g14", "shanks"}) || len(a.NotHeard) != 0 {
|
if !reflect.DeepEqual(a.From, []string{"node-b", "node-d"}) || len(a.NotHeard) != 0 {
|
||||||
t.Fatalf("derived from the nodes holding Betterbird: %v %v", a.From, a.NotHeard)
|
t.Fatalf("derived from the nodes holding Betterbird: %v %v", a.From, a.NotHeard)
|
||||||
}
|
}
|
||||||
whole := mustJSON(a)
|
whole := mustJSON(a)
|
||||||
@@ -134,9 +134,9 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
|
|||||||
}
|
}
|
||||||
cals, _ := mw[kindCalendars].(map[string]any)
|
cals, _ := mw[kindCalendars].(map[string]any)
|
||||||
if cals["home"] == nil || cals["standup"] != nil {
|
if cals["home"] == nil || cals["standup"] != nil {
|
||||||
t.Errorf("a calendar both hold is mesh-wide, one only shanks holds is not: %v", cals)
|
t.Errorf("a calendar both hold is mesh-wide, one only node-d holds is not: %v", cals)
|
||||||
}
|
}
|
||||||
if sh := mustJSON(a.Layers["shanks"]); !strings.Contains(sh, "https://dav.example.org/cal/standup/") {
|
if sh := mustJSON(a.Layers["node-d"]); !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)
|
t.Errorf("an entry on one node only goes into that node's layer: %s", sh)
|
||||||
}
|
}
|
||||||
tags, _ := mw[kindTags].(map[string]any)
|
tags, _ := mw[kindTags].(map[string]any)
|
||||||
@@ -147,8 +147,8 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
|
|||||||
for _, d := range a.Differences {
|
for _, d := range a.Differences {
|
||||||
diffs[d.Kind+"/"+d.Entry+"/"+d.Field] = d
|
diffs[d.Kind+"/"+d.Entry+"/"+d.Field] = d
|
||||||
}
|
}
|
||||||
if d := diffs["identities/mail/signature"]; d.Node != "shanks" || mustJSON(d.There) != `["Cheers"]` {
|
if d := diffs["identities/mail/signature"]; d.Node != "node-d" || mustJSON(d.There) != `["Cheers"]` {
|
||||||
t.Errorf("the answer lists the field shanks holds differently: %+v", a.Differences)
|
t.Errorf("the answer lists the field node-d holds differently: %+v", a.Differences)
|
||||||
}
|
}
|
||||||
if d := diffs["tags/$label1/color"]; d.There != "#CC0000" || d.MeshWide != "#FF0000" {
|
if d := diffs["tags/$label1/color"]; d.There != "#CC0000" || d.MeshWide != "#FF0000" {
|
||||||
t.Errorf("%+v", a.Differences)
|
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 {
|
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)
|
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 shanks's own.
|
// Approved: the mesh-wide layer holds it, so the next layer is node-d's own.
|
||||||
mesh.open = false
|
mesh.open = false
|
||||||
mesh.layers[""] = values
|
mesh.layers[""] = values
|
||||||
a, err = f.Derive(nil, false, true, prefs, "")
|
a, err = f.Derive(nil, false, true, prefs, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "shanks" || a.Next != "shanks" ||
|
if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "node-d" || a.Next != "node-d" ||
|
||||||
!strings.Contains(mesh.proposed[1]["values"].(string), "standup") {
|
!strings.Contains(mesh.proposed[1]["values"].(string), "standup") {
|
||||||
t.Fatalf("then the node's own layer: %v", mesh.proposed)
|
t.Fatalf("then the node's own layer: %v", mesh.proposed)
|
||||||
}
|
}
|
||||||
var shanks map[string]any
|
var nodeD map[string]any
|
||||||
if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &shanks); err != nil {
|
if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &nodeD); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mesh.layers["shanks"] = shanks
|
mesh.layers["node-d"] = nodeD
|
||||||
if a, err = f.Derive(nil, false, true, prefs, ""); err != nil || len(mesh.proposed) != 2 || !strings.HasPrefix(a.Next, "none") {
|
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)
|
t.Fatalf("nothing left to propose: %v %s", err, a.Next)
|
||||||
}
|
}
|
||||||
@@ -240,10 +240,10 @@ func TestALayerThatCannotBeReadRefusesTheProposal(t *testing.T) {
|
|||||||
f2 := newFake(t)
|
f2 := newFake(t)
|
||||||
f2.profileWithAccounts()
|
f2.profileWithAccounts()
|
||||||
f2.declareAccounts(adoptable)
|
f2.declareAccounts(adoptable)
|
||||||
mesh := &derivingMesh{t: t, shanks: f2, garbled: true}
|
mesh := &derivingMesh{t: t, nodeD: f2, garbled: true}
|
||||||
oldNode, oldAsk := nodeName, asker
|
oldNode, oldAsk := nodeName, asker
|
||||||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||||||
nodeName = func() string { return "g14" }
|
nodeName = func() string { return "node-b" }
|
||||||
asker = mesh.ask
|
asker = mesh.ask
|
||||||
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
|
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
|
||||||
len(mesh.proposed) != 0 {
|
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")
|
t.Fatal("an unreadable layer is an error, never an empty layer")
|
||||||
}
|
}
|
||||||
mesh.garbled = false
|
mesh.garbled = false
|
||||||
if got, err := readLayer("shanks"); err != nil || len(got) != 0 {
|
if got, err := readLayer("node-d"); err != nil || len(got) != 0 {
|
||||||
t.Fatalf("no layer, in the controller's words, is an empty layer: %v %v", got, err)
|
t.Fatalf("no layer, in the controller's words, is an empty layer: %v %v", got, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
s := strings.ToLower(raw)
|
s := strings.ToLower(raw)
|
||||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http://", "mediahuis"} {
|
// 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"} {
|
||||||
if strings.Contains(s, never) {
|
if strings.Contains(s, never) {
|
||||||
t.Errorf("module.json names %q", 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
|
## What changes when it is assigned
|
||||||
|
|
||||||
| | g14 | shanks |
|
| | node-b | node-d |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same |
|
| 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 |
|
| 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)
|
## Migration (ADR 0182)
|
||||||
|
|
||||||
Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and
|
Nothing is required on either machine. On node-d, log out and in once, or run `blueman_restart`, and
|
||||||
the applet runs from its one start. `blueman_check` then answers `ok`.
|
the applet runs from its one start. `blueman_check` then answers `ok`.
|
||||||
|
|
||||||
## Leaves as found
|
## Leaves as found
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
|||||||
t.Errorf("the Go tools bundle: %v", b)
|
t.Errorf("the Go tools bundle: %v", b)
|
||||||
}
|
}
|
||||||
s := strings.ToLower(raw)
|
s := strings.ToLower(raw)
|
||||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
// 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"} {
|
||||||
if strings.Contains(s, never) {
|
if strings.Contains(s, never) {
|
||||||
t.Errorf("module.json names %q", 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: 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"
|
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"
|
||||||
|
|
||||||
func headphones(connected bool) string {
|
func headphones(connected bool) string {
|
||||||
c := "no"
|
c := "no"
|
||||||
@@ -34,7 +34,7 @@ func headphones(connected bool) string {
|
|||||||
func TestControllerReadsShow(t *testing.T) {
|
func TestControllerReadsShow(t *testing.T) {
|
||||||
using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
|
using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
|
||||||
c, err := Controller()
|
c, err := Controller()
|
||||||
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
|
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "node-b" || !c.Powered || c.Discoverable || !c.Pairable {
|
||||||
t.Fatalf("%+v %v", c, err)
|
t.Fatalf("%+v %v", c, err)
|
||||||
}
|
}
|
||||||
using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
|
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 {
|
Retention: jetstream.WorkQueuePolicy, Discard: jetstream.DiscardNew, MaxAge: 24 * time.Hour, MaxBytes: 8 << 20}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
account := process.Account{URL: url, Node: "g14", Module: "brother", User: "g14.brother." + DeviceProcessID, Password: "pw"}
|
account := process.Account{URL: url, Node: "node-b", Module: "brother", User: "node-b.brother." + DeviceProcessID, Password: "pw"}
|
||||||
if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(),
|
if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(),
|
||||||
AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil {
|
AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil {
|
||||||
t.Fatal(err)
|
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.
|
// A refusal inside the mesh is answered, and nothing is stored nor printed.
|
||||||
fake.body = nil
|
fake.body = nil
|
||||||
before := len(r.objects.keys())
|
before := len(r.objects.keys())
|
||||||
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") {
|
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") {
|
||||||
t.Errorf("a loopback address: error = %v", err)
|
t.Errorf("a loopback address: error = %v", err)
|
||||||
}
|
}
|
||||||
if len(r.objects.keys()) != before || fake.body != nil {
|
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{
|
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"),
|
"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"),
|
"path": str("an absolute path of a file on the machine node names"),
|
||||||
"node": str("the machine path is on, such as g14"),
|
"node": str("the machine path is on, such as node-b"),
|
||||||
"url": str("an http or https address of a document or a page"),
|
"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"),
|
"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)"),
|
"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{
|
for name, args := range map[string]map[string]any{
|
||||||
"none": {},
|
"none": {},
|
||||||
"only options": {"copies": float64(2)},
|
"only options": {"copies": float64(2)},
|
||||||
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "g14"},
|
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "node-b"},
|
||||||
"object and url": {"object": key, "url": "https://example.com/a.pdf"},
|
"object and url": {"object": key, "url": "https://example.com/a.pdf"},
|
||||||
"path+node and url": {"path": "/home/ops/a.pdf", "node": "g14", "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": "g14", "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 without node": {"path": "/home/ops/a.pdf"},
|
"path without node": {"path": "/home/ops/a.pdf"},
|
||||||
"node without path": {"node": "g14", "object": key},
|
"node without path": {"node": "node-b", "object": key},
|
||||||
"the retired upload": {"upload": "u1"},
|
"the retired upload": {"upload": "u1"},
|
||||||
"object and upload": {"object": key, "upload": "u1"},
|
"object and upload": {"object": key, "upload": "u1"},
|
||||||
"path and its object": {"path": "/home/ops/a.pdf", "node": "g14", "object": key},
|
"path and its object": {"path": "/home/ops/a.pdf", "node": "node-b", "object": key},
|
||||||
} {
|
} {
|
||||||
_, err := r.call(t, "printer.print", args)
|
_, err := r.call(t, "printer.print", args)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -82,7 +82,7 @@ func TestPrintTakesExactlyOneSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1598/AC2: with path and node g14, printer.print asks g14's node-files to upload the path into brother's bucket, and
|
// 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
|
||||||
// queues a print ask that carries the returned key and the options: under 4 KiB, and no path, node or bytes.
|
// 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) {
|
func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
|
||||||
fake := newIPPFake(t, 31, 5)
|
fake := newIPPFake(t, 31, 5)
|
||||||
@@ -90,12 +90,12 @@ func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
|
|||||||
key := sha256Hex(doc)
|
key := sha256Hex(doc)
|
||||||
up := withUpload(t, key, nil)
|
up := withUpload(t, key, nil)
|
||||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||||
r.objects.Put(t.Context(), key, "application/pdf", doc) // what g14's upload stored
|
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": "g14", "copies": float64(2)})
|
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "node-b", "copies": float64(2)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(up.seen()) != 1 || up.seen()[0] != "g14|/home/ops/letter.pdf|brother-test" {
|
if len(up.seen()) != 1 || up.seen()[0] != "node-b|/home/ops/letter.pdf|brother-test" {
|
||||||
t.Fatalf("uploads asked: %v", up.seen())
|
t.Fatalf("uploads asked: %v", up.seen())
|
||||||
}
|
}
|
||||||
if len(r.bus.published) != 1 {
|
if len(r.bus.published) != 1 {
|
||||||
@@ -114,7 +114,7 @@ func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m := got.(map[string]any)
|
m := got.(map[string]any)
|
||||||
if m["job-id"] != float64(31) || m["node"] != "g14" || m["path"] != "/home/ops/letter.pdf" {
|
if m["job-id"] != float64(31) || m["node"] != "node-b" || m["path"] != "/home/ops/letter.pdf" {
|
||||||
t.Errorf("answer = %v", m)
|
t.Errorf("answer = %v", m)
|
||||||
}
|
}
|
||||||
if _, sent := fake.request(t); string(sent) != string(doc) {
|
if _, sent := fake.request(t); string(sent) != string(doc) {
|
||||||
@@ -127,7 +127,7 @@ func TestARefusedUploadIsAnsweredWithItsReasonAndNothingIsPrinted(t *testing.T)
|
|||||||
fake := newIPPFake(t, 1, 3)
|
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"))
|
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")
|
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": "g14"})
|
_, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "node-b"})
|
||||||
if err == nil || !strings.Contains(err.Error(), "is refused by name: it is in .ssh") {
|
if err == nil || !strings.Contains(err.Error(), "is refused by name: it is in .ssh") {
|
||||||
t.Fatalf("error = %v", err)
|
t.Fatalf("error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -277,7 +277,7 @@ func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
|
|||||||
t.Cleanup(func() { uploadVia = old })
|
t.Cleanup(func() { uploadVia = old })
|
||||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
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": "g14"})
|
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "node-b"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("a slow upload failed the call: %v", err)
|
t.Fatalf("a slow upload failed the call: %v", err)
|
||||||
}
|
}
|
||||||
@@ -292,7 +292,7 @@ func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if rm := read.(map[string]any); rm["state"] == nil {
|
if rm := read.(map[string]any); rm["state"] == nil {
|
||||||
if rm["job-id"] != float64(72) || rm["path"] != "/home/ops/letter.pdf" || rm["node"] != "g14" {
|
if rm["job-id"] != float64(72) || rm["path"] != "/home/ops/letter.pdf" || rm["node"] != "node-b" {
|
||||||
t.Errorf("the outcome = %v", rm)
|
t.Errorf("the outcome = %v", rm)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -309,7 +309,7 @@ func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
|
|||||||
time.Sleep(150 * time.Millisecond)
|
time.Sleep(150 * time.Millisecond)
|
||||||
return "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name")
|
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": "g14"})
|
got, _ = r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "node-b"})
|
||||||
id2, _ := got.(map[string]any)["ask"].(string)
|
id2, _ := got.(map[string]any)["ask"].(string)
|
||||||
time.Sleep(300 * time.Millisecond)
|
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") {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
Answers: answers,
|
Answers: answers,
|
||||||
Node: "shanks",
|
Node: "node-d",
|
||||||
Now: func() time.Time { return fixedNow },
|
Now: func() time.Time { return fixedNow },
|
||||||
}
|
}
|
||||||
bus := &fakeAskBus{worker: w, answers: answers}
|
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.
|
// 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: "novox.internal", Port: "20001", Bucket: "mesh-shanks-brother", Region: "eu-west",
|
s3 := NewS3(StoreFacts{Scheme: "http", At: "node-c.internal", Port: "20001", Bucket: "mesh-node-d-brother", Region: "eu-west",
|
||||||
Key: "binding-key", Secret: "binding-secret"})
|
Key: "binding-key", Secret: "binding-secret"})
|
||||||
s3.Now = func() time.Time { return fixedNow }
|
s3.Now = func() time.Time { return fixedNow }
|
||||||
got, err := s3.Link("GET", "abc.pdf", 10*time.Minute)
|
got, err := s3.Link("GET", "abc.pdf", 10*time.Minute)
|
||||||
@@ -84,7 +84,7 @@ func TestTheDeviceAnswerCarriesALinkAndNoBytes(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
gu, _ := url.Parse(got)
|
gu, _ := url.Parse(got)
|
||||||
if gu.Host != "novox.internal:20001" || gu.Path != "/mesh-shanks-brother/abc.pdf" ||
|
if gu.Host != "node-c.internal:20001" || gu.Path != "/mesh-node-d-brother/abc.pdf" ||
|
||||||
!strings.HasPrefix(gu.Query().Get("X-Amz-Credential"), "binding-key/") || gu.Query().Get("X-Amz-Expires") != "600" {
|
!strings.HasPrefix(gu.Query().Get("X-Amz-Credential"), "binding-key/") || gu.Query().Get("X-Amz-Expires") != "600" {
|
||||||
t.Errorf("link %s", got)
|
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) {
|
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)
|
t.Errorf("the bucket does not hold the filed PDF under its sha256 (%v)", err)
|
||||||
}
|
}
|
||||||
if ev.body["bytes"] != len(pdf) || ev.body["node"] != "shanks" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" {
|
if ev.body["bytes"] != len(pdf) || ev.body["node"] != "node-d" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" {
|
||||||
t.Errorf("event = %v", ev.body)
|
t.Errorf("event = %v", ev.body)
|
||||||
}
|
}
|
||||||
if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want {
|
if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# buildah
|
||||||
|
|
||||||
|
The rootless image builder as a module (novox/hq ADR 0487, issue 1702).
|
||||||
|
|
||||||
|
- **The only module that declares the package `buildah`** (`checks/toolchains`), and the only one that provides
|
||||||
|
`rootless-image-build` (`checks/imagebuild`; docker never does).
|
||||||
|
- **Provides `rootless-image-build` with the machine's reach**: mesh-build-image requires it, so it composes only on a
|
||||||
|
machine where this module is assigned, and a node without it is refused at resolution naming `buildah`.
|
||||||
|
- **No account and no subordinate-id word.** The node-engine makes every `user` account with `useradd`, which
|
||||||
|
allocates the account 65536 subordinate uids and gids; mesh-build-image's own account gets its ranges that way and
|
||||||
|
its health proves them (it reads /etc/subuid and /etc/subgid).
|
||||||
|
- The helpers (an OCI runtime, passt, containers-common) come as buildah's package dependencies.
|
||||||
|
- Holds no long-running resource, so it adds nothing to `health-undeclared`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"module": "buildah",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager",
|
||||||
|
"user-namespaces"
|
||||||
|
],
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"name": "rootless-image-build",
|
||||||
|
"reach": "machine"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "buildah",
|
||||||
|
"type": "package",
|
||||||
|
"package": "buildah"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -290,11 +290,11 @@ func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
|
|||||||
p, w := node(t, "laptop")
|
p, w := node(t, "laptop")
|
||||||
var asked []string
|
var asked []string
|
||||||
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
|
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
|
||||||
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
|
Why: "the switch verb", By: "node-b.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
|
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"node-b.node-tools"`} {
|
||||||
if !strings.Contains(done, want) {
|
if !strings.Contains(done, want) {
|
||||||
t.Fatalf("the line %s lacks %s", 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) {
|
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
|
||||||
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
|
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"node-a", "node-b"}, nil })
|
||||||
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
|
if err != nil || strings.Join(nodes, ",") != "node-a,node-b" {
|
||||||
t.Fatalf("%v %v", nodes, err)
|
t.Fatalf("%v %v", nodes, err)
|
||||||
}
|
}
|
||||||
p, w := node(t, "laptop")
|
p, w := node(t, "laptop")
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ func TestTheSessionsGitIsPointedAtTheFileUnderTheOperatorsOwnEnvironment(t *test
|
|||||||
|
|
||||||
func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
|
func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
|
||||||
for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test",
|
for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test",
|
||||||
"https://agent-anchor:t@git.example.test/novox/hq.git", "ssh://agent-anchor:t@git.example.test",
|
"https://agent-anchor:t@git.example.test/acme/hq.git", "ssh://agent-anchor:t@git.example.test",
|
||||||
"https://agent-anchor:t@git.example.test\nnot a line"} {
|
"https://agent-anchor:t@git.example.test\nnot a line"} {
|
||||||
if _, err := ForgeCredentialLines(bad); err == nil {
|
if _, err := ForgeCredentialLines(bad); err == nil {
|
||||||
t.Errorf("%q was taken", bad)
|
t.Errorf("%q was taken", bad)
|
||||||
|
|||||||
@@ -131,13 +131,13 @@ func TestWhatIsNotAWorkAroundPasses(t *testing.T) {
|
|||||||
g := guard()
|
g := guard()
|
||||||
for _, command := range []string{
|
for _, command := range []string{
|
||||||
"git push -u origin feat/x",
|
"git push -u origin feat/x",
|
||||||
"git clone ssh://git@git.anchor.internal:222/novox/hq.git",
|
"git clone ssh://git@git.anchor.internal:222/acme/hq.git",
|
||||||
"git fetch ssh://git@git.anchor.internal:222/novox/hq.git main",
|
"git fetch ssh://git@git.anchor.internal:222/acme/hq.git main",
|
||||||
`GIT_SSH_COMMAND="ssh -i ~/.ssh/forge -o IdentitiesOnly=yes" git push origin HEAD`,
|
`GIT_SSH_COMMAND="ssh -i ~/.ssh/forge -o IdentitiesOnly=yes" git push origin HEAD`,
|
||||||
"git -c core.sshCommand='ssh -p 222' pull",
|
"git -c core.sshCommand='ssh -p 222' pull",
|
||||||
"ssh -T git@git.anchor.internal -p 222",
|
"ssh -T git@git.anchor.internal -p 222",
|
||||||
"ssh forge",
|
"ssh forge",
|
||||||
"scp git@git.anchor.internal:novox/hq.git .",
|
"scp git@git.anchor.internal:acme/hq.git .",
|
||||||
"ssh github.com",
|
"ssh github.com",
|
||||||
"ssh -T git@github.com",
|
"ssh -T git@github.com",
|
||||||
"ssh user@203.0.113.7 uptime",
|
"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.
|
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
|
||||||
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
|
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||||
t.Cleanup(func() { waiting = nil })
|
t.Cleanup(func() { waiting = nil })
|
||||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
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@g14",
|
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@node-b",
|
||||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||||
if isErr {
|
if isErr {
|
||||||
t.Fatalf("add: %s", text)
|
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.
|
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
|
||||||
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
|
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
clock := t0
|
clock := t0
|
||||||
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
|
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
|
||||||
t.Cleanup(func() { waiting = nil })
|
t.Cleanup(func() { waiting = nil })
|
||||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
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@g14",
|
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@node-b",
|
||||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
|
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
|
||||||
if isErr {
|
if isErr {
|
||||||
t.Fatalf("add: %s", text)
|
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
|
// 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.
|
// regression here is caught the same way as the Stop hook's.
|
||||||
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
|
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||||
t.Cleanup(func() { waiting = nil })
|
t.Cleanup(func() { waiting = nil })
|
||||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
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@g14",
|
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@node-b",
|
||||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||||
if isErr {
|
if isErr {
|
||||||
t.Fatalf("add: %s", text)
|
t.Fatalf("add: %s", text)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
|
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
|
||||||
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
|
if !strings.Contains(msg, "\"by\":\"fix-1757@node-b\"") {
|
||||||
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
|
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
|
||||||
}
|
}
|
||||||
args := suggestedCall(t, 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
|
// 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 shanks: "MEMORY.older-from-shanks.md").
|
// the node it was changed on ("MEMORY.md" from node-d: "MEMORY.older-from-node-d.md").
|
||||||
func OlderBeside(path, node string) string {
|
func OlderBeside(path, node string) string {
|
||||||
ext := filepath.Ext(path)
|
ext := filepath.Ext(path)
|
||||||
return strings.TrimSuffix(path, ext) + ".older-from-" + node + ext
|
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
|
// 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
|
// 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 shanks on 2026-10-13). Drain applies
|
// starts on a node with a few hundred memory files (issue 367, seen on node-d on 2026-10-13). Drain applies
|
||||||
// what was noted, the newest revision of each key.
|
// what was noted, the newest revision of each key.
|
||||||
m.queueMu.Lock()
|
m.queueMu.Lock()
|
||||||
if m.queue == nil {
|
if m.queue == nil {
|
||||||
|
|||||||
@@ -206,15 +206,15 @@ func holdsText(list []string, s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = "-home-op-projects-novox-hq"
|
const project = "-home-op-projects-acme-hq"
|
||||||
|
|
||||||
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
|
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)}
|
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
|
return state, c, newMemNode(t, root, "node-b", HomeOperator, state, c), newMemNode(t, root, "node-d", HomeOperator, state, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// carried writes a file on g14 and carries it to shanks.
|
// carried writes a file on node-b and carries it to node-d.
|
||||||
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
|
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
a.write(t, project, file, text, c.now)
|
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.
|
// §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) {
|
func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
|
nodeB.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)
|
nodeB.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(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"} {
|
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 := shanks.read(t, project, file); !ok || got != want {
|
if got, ok := nodeD.read(t, project, file); !ok || got != want {
|
||||||
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
|
t.Fatalf("%s on node-d: %q, %v", file, got, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
before := state.rev
|
before := state.rev
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if state.rev != before {
|
if state.rev != before {
|
||||||
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
|
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
|
||||||
}
|
}
|
||||||
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
|
// A change on node-d comes back to node-b, over the version it replaced, which node-b keeps.
|
||||||
c.now = c.now.Add(time.Minute)
|
c.now = c.now.Add(time.Minute)
|
||||||
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
|
nodeD.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
|
if got, _ := nodeB.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
|
||||||
t.Fatalf("the change did not reach g14: %q", got)
|
t.Fatalf("the change did not reach node-b: %q", got)
|
||||||
}
|
}
|
||||||
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
|
if !holdsText(nodeB.copies(t), "ADR 0343 proposed.\n") {
|
||||||
t.Fatal("the version replaced on g14 was not kept")
|
t.Fatal("the version replaced on node-b was not kept")
|
||||||
}
|
}
|
||||||
if len(g14.notices)+len(shanks.notices) != 0 {
|
if len(nodeB.notices)+len(nodeD.notices) != 0 {
|
||||||
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
|
t.Fatalf("a plain change warned: %v %v", nodeB.notices, nodeD.notices)
|
||||||
}
|
}
|
||||||
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
|
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
|
||||||
raw, _ := json.Marshal(g14.Status())
|
raw, _ := json.Marshal(nodeB.Status())
|
||||||
if strings.Contains(string(raw), "accepted") {
|
if strings.Contains(string(raw), "accepted") {
|
||||||
t.Fatalf("the memory tool answered the text: %s", raw)
|
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
|
// §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.
|
// listed with why.
|
||||||
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
|
projectDir := filepath.Join(nodeB.Home, ".claude", "projects", project)
|
||||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
nodeB.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||||
for path, text := range map[string]string{
|
for path, text := range map[string]string{
|
||||||
filepath.Join(projectDir, "session.jsonl"): "{}\n",
|
filepath.Join(projectDir, "session.jsonl"): "{}\n",
|
||||||
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
|
filepath.Join(nodeB.Home, ".claude", "settings.json"): "{}\n",
|
||||||
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
|
filepath.Join(nodeB.Home, ".claude", "history.jsonl"): "{}\n",
|
||||||
} {
|
} {
|
||||||
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
|
nodeB.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)
|
nodeB.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)
|
nodeB.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
|
||||||
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
|
nodeB.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
|
||||||
outside := filepath.Join(t.TempDir(), "elsewhere.md")
|
outside := filepath.Join(t.TempDir(), "elsewhere.md")
|
||||||
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
|
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
|
||||||
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
|
if err := os.Symlink(outside, nodeB.path(project, "link.md")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
||||||
if _, ok := shanks.read(t, project, file); ok {
|
if _, ok := nodeD.read(t, project, file); ok {
|
||||||
t.Errorf("%s was carried", file)
|
t.Errorf("%s was carried", file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, file := range []string{"MEMORY.md", "hash.md"} {
|
for _, file := range []string{"MEMORY.md", "hash.md"} {
|
||||||
if _, ok := shanks.read(t, project, file); !ok {
|
if _, ok := nodeD.read(t, project, file); !ok {
|
||||||
t.Errorf("%s was not carried", file)
|
t.Errorf("%s was not carried", file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
|
if _, err := os.Stat(filepath.Join(nodeD.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
|
||||||
t.Error("a session was carried")
|
t.Error("a session was carried")
|
||||||
}
|
}
|
||||||
for _, f := range []string{"settings.json", "history.jsonl"} {
|
for _, f := range []string{"settings.json", "history.jsonl"} {
|
||||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
|
if _, err := os.Stat(filepath.Join(nodeD.Home, ".claude", f)); err == nil {
|
||||||
t.Errorf("%s was carried", f)
|
t.Errorf("%s was carried", f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +315,7 @@ func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
|||||||
if len(keys) != 2 {
|
if len(keys) != 2 {
|
||||||
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
|
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
|
||||||
}
|
}
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
not, _ := nodeB.Status()["not carried"].(map[string]string)
|
||||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
||||||
if not[project+"/"+file] == "" {
|
if not[project+"/"+file] == "" {
|
||||||
t.Errorf("%s is not listed as not carried: %v", file, not)
|
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) {
|
func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
|
||||||
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
|
laptop := newMemNode(t, root, "node-b", HomeOperator, state, c)
|
||||||
control := newMemNode(t, root, "novox", HomeAgent, state, c)
|
control := newMemNode(t, root, "node-c", HomeAgent, state, c)
|
||||||
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
|
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
|
||||||
control.look(t)
|
control.look(t)
|
||||||
state.pump(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.
|
// A value of the other kind put under this kind's key is refused too.
|
||||||
key := MemoryKey(HomeOperator, project, "x.md")
|
key := MemoryKey(HomeOperator, project, "x.md")
|
||||||
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
|
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "node-c"})
|
||||||
if err := laptop.Take(key, "put", raw, 99); err == nil {
|
if err := laptop.Take(key, "put", raw, 99); err == nil {
|
||||||
t.Fatal("a value of another kind was taken")
|
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
|
// §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.
|
// one warning; it clears once the file is tidied.
|
||||||
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
|
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "- base\n")
|
||||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
|
nodeB.write(t, project, "MEMORY.md", "- base\n- learned on node-b\n", c.now.Add(time.Minute))
|
||||||
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute))
|
nodeD.write(t, project, "MEMORY.md", "- base\n- learned on node-d\n", c.now.Add(2*time.Minute))
|
||||||
c.now = c.now.Add(3 * time.Minute)
|
c.now = c.now.Add(3 * time.Minute)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
a, _ := g14.read(t, project, "MEMORY.md")
|
a, _ := nodeB.read(t, project, "MEMORY.md")
|
||||||
b, _ := shanks.read(t, project, "MEMORY.md")
|
b, _ := nodeD.read(t, project, "MEMORY.md")
|
||||||
if a != b {
|
if a != b {
|
||||||
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", 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 shanks"), strings.Index(a, "learned on g14")
|
newer, older := strings.Index(a, "learned on node-d"), strings.Index(a, "learned on node-b")
|
||||||
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
|
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
|
||||||
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
|
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)
|
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
|
||||||
}
|
}
|
||||||
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
|
if !strings.Contains(a[marker:], "changed on node-d") || !strings.Contains(a[marker:], "changed on node-b") {
|
||||||
t.Fatalf("the line does not name both nodes:\n%s", a)
|
t.Fatalf("the line does not name both nodes:\n%s", a)
|
||||||
}
|
}
|
||||||
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
notices := append(append([]map[string]any{}, nodeB.notices...), nodeD.notices...)
|
||||||
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
|
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
|
||||||
t.Fatalf("not one warning: %v", notices)
|
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 {
|
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
|
||||||
t.Errorf("the headline is longer than sixty characters: %q", h)
|
t.Errorf("the headline is longer than sixty characters: %q", h)
|
||||||
}
|
}
|
||||||
all := append(g14.copies(t), shanks.copies(t)...)
|
all := append(nodeB.copies(t), nodeD.copies(t)...)
|
||||||
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
|
for _, v := range []string{"- base\n- learned on node-b\n", "- base\n- learned on node-d\n"} {
|
||||||
if !holdsText(all, v) {
|
if !holdsText(all, v) {
|
||||||
t.Errorf("the version %q was not kept", v)
|
t.Errorf("the version %q was not kept", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
|
if st := nodeB.Status(); len(st["conflicts"].([]conflict))+len(nodeD.Status()["conflicts"].([]conflict)) != 1 {
|
||||||
t.Fatalf("the conflict is not listed: %v", st)
|
t.Fatalf("the conflict is not listed: %v", st)
|
||||||
}
|
}
|
||||||
// Tidied on either node, the warning clears.
|
// Tidied on either node, the warning clears.
|
||||||
c.now = c.now.Add(time.Minute)
|
c.now = c.now.Add(time.Minute)
|
||||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
|
nodeB.write(t, project, "MEMORY.md", "- base\n- learned on node-b\n- learned on node-d\n", c.now)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
cleared := false
|
cleared := false
|
||||||
for _, n := range append(g14.notices, shanks.notices...) {
|
for _, n := range append(nodeB.notices, nodeD.notices...) {
|
||||||
if n["clear"] == true {
|
if n["clear"] == true {
|
||||||
cleared = true
|
cleared = true
|
||||||
}
|
}
|
||||||
@@ -429,39 +429,39 @@ func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T
|
|||||||
if !cleared {
|
if !cleared {
|
||||||
t.Fatal("the warning did not clear once the file was tidied")
|
t.Fatal("the warning did not clear once the file was tidied")
|
||||||
}
|
}
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
|
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 shanks: %q", got)
|
t.Fatalf("the tidied file did not reach node-d: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The order of the two versions: the later change first, equal times by node name.
|
// The order of the two versions: the later change first, equal times by node name.
|
||||||
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
|
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
|
||||||
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||||
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
|
m := string(MergeVersions([]byte("old\n"), "node-b", at, []byte("new\n"), "node-d", at.Add(time.Second)))
|
||||||
if strings.Index(m, "new") > strings.Index(m, "old") {
|
if strings.Index(m, "new") > strings.Index(m, "old") {
|
||||||
t.Fatalf("the newer is not first: %s", m)
|
t.Fatalf("the newer is not first: %s", m)
|
||||||
}
|
}
|
||||||
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
|
m = string(MergeVersions([]byte("from node-d\n"), "node-d", at, []byte("from node-a\n"), "node-a", at))
|
||||||
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
|
if strings.Index(m, "from node-a") > strings.Index(m, "from node-d") {
|
||||||
t.Fatalf("equal times are not ordered by node name: %s", m)
|
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.
|
// §9: a removal moves the other node's unchanged file into the copies.
|
||||||
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "old.md", "an old note\n")
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "index\n")
|
||||||
if err := os.Remove(g14.path(project, "old.md")); err != nil {
|
if err := os.Remove(nodeB.path(project, "old.md")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if _, ok := shanks.read(t, project, "old.md"); ok {
|
if _, ok := nodeD.read(t, project, "old.md"); ok {
|
||||||
t.Fatal("the removal did not reach shanks")
|
t.Fatal("the removal did not reach node-d")
|
||||||
}
|
}
|
||||||
if !holdsText(shanks.copies(t), "an old note\n") {
|
if !holdsText(nodeD.copies(t), "an old note\n") {
|
||||||
t.Fatal("the removed version was not kept on shanks")
|
t.Fatal("the removed version was not kept on node-d")
|
||||||
}
|
}
|
||||||
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
|
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
|
||||||
var v MemoryVersion
|
var v MemoryVersion
|
||||||
@@ -469,7 +469,7 @@ func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
|||||||
if !ok || !v.Removed || v.Text != "" {
|
if !ok || !v.Removed || v.Text != "" {
|
||||||
t.Fatalf("the removal is not a marker in the state: %s", raw)
|
t.Fatalf("the removal is not a marker in the state: %s", raw)
|
||||||
}
|
}
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
t.Fatal("another file was touched")
|
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.
|
// §9: a change outlives a removal, whichever reaches the state first.
|
||||||
func TestAChangeOutlivesARemoval(t *testing.T) {
|
func TestAChangeOutlivesARemoval(t *testing.T) {
|
||||||
for _, removalFirst := range []bool{true, false} {
|
for _, removalFirst := range []bool{true, false} {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "note.md", "v1\n")
|
||||||
_ = os.Remove(g14.path(project, "note.md"))
|
_ = os.Remove(nodeB.path(project, "note.md"))
|
||||||
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
nodeD.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
||||||
if removalFirst {
|
if removalFirst {
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
} else {
|
} else {
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
}
|
}
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
for _, n := range []*memNode{nodeB, nodeD} {
|
||||||
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
|
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)
|
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.
|
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
|
||||||
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
|
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "index\n")
|
||||||
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
||||||
// The folder gone on g14.
|
// The folder gone on node-b.
|
||||||
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
|
if err := os.RemoveAll(filepath.Join(nodeB.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
t.Fatal("a missing folder removed the file elsewhere")
|
t.Fatal("a missing folder removed the file elsewhere")
|
||||||
}
|
}
|
||||||
// A deleted key.
|
// A deleted key.
|
||||||
_ = state.Delete(key)
|
_ = state.Delete(key)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
t.Fatal("a deleted key removed the file")
|
t.Fatal("a deleted key removed the file")
|
||||||
}
|
}
|
||||||
// The state lost: the file is carried again, not removed.
|
// The state lost: the file is carried again, not removed.
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if _, _, ok, _ := state.Get(key); !ok {
|
if _, _, ok, _ := state.Get(key); !ok {
|
||||||
t.Fatal("a file whose key the state lost was not carried again")
|
t.Fatal("a file whose key the state lost was not carried again")
|
||||||
}
|
}
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
t.Fatalf("the file was touched on shanks: %q", got)
|
t.Fatalf("the file was touched on node-d: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
|
// §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) {
|
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
|
nodeB.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
|
||||||
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
|
nodeB.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))
|
nodeD.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)
|
nodeD.write(t, project, "workstation.md", "only on the workstation\n", c.now)
|
||||||
shanks.write(t, project, "same.md", "same\n", c.now)
|
nodeD.write(t, project, "same.md", "same\n", c.now)
|
||||||
g14.write(t, project, "same.md", "same\n", c.now)
|
nodeB.write(t, project, "same.md", "same\n", c.now)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
for _, n := range []*memNode{nodeB, nodeD} {
|
||||||
index, _ := n.read(t, project, "MEMORY.md")
|
index, _ := n.read(t, project, "MEMORY.md")
|
||||||
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
|
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") {
|
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
|
||||||
@@ -559,23 +559,23 @@ func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n := len(g14.notices) + len(shanks.notices); n != 1 {
|
if n := len(nodeB.notices) + len(nodeD.notices); n != 1 {
|
||||||
t.Fatalf("%d warnings for one conflict", n)
|
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.
|
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
|
||||||
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
|
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
elsewhere := t.TempDir()
|
elsewhere := t.TempDir()
|
||||||
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Join(nodeD.Home, ".claude", "projects", project), 0o700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
|
if err := os.Symlink(elsewhere, filepath.Join(nodeD.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
nodeB.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
|
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
|
||||||
t.Fatalf("written through a symbolic link: %v", entries)
|
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
|
// §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.
|
// its own file holds no change it did not carry; the version replaced is kept.
|
||||||
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
|
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "v1\n")
|
||||||
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
state.watchers = []*MemoryCarrier{nodeB.MemoryCarrier}
|
||||||
for _, v := range []string{"v2\n", "v3\n"} {
|
for _, v := range []string{"v2\n", "v3\n"} {
|
||||||
c.now = c.now.Add(time.Minute)
|
c.now = c.now.Add(time.Minute)
|
||||||
g14.write(t, project, "MEMORY.md", v, c.now)
|
nodeB.write(t, project, "MEMORY.md", v, c.now)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
}
|
}
|
||||||
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
state.watchers = append(state.watchers, nodeD.MemoryCarrier)
|
||||||
state.replay(t, shanks)
|
state.replay(t, nodeD)
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" {
|
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "v3\n" {
|
||||||
t.Fatalf("the node that was away holds %q", got)
|
t.Fatalf("the node that was away holds %q", got)
|
||||||
}
|
}
|
||||||
if !holdsText(shanks.copies(t), "v1\n") {
|
if !holdsText(nodeD.copies(t), "v1\n") {
|
||||||
t.Fatal("the version replaced was not kept")
|
t.Fatal("the version replaced was not kept")
|
||||||
}
|
}
|
||||||
if len(g14.notices)+len(shanks.notices) != 0 {
|
if len(nodeB.notices)+len(nodeD.notices) != 0 {
|
||||||
t.Fatal("taking a newer version warned")
|
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,
|
// §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.
|
// both carried; and a text that would not fit in one value of the state is not carried.
|
||||||
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
|
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "big.md", "base\n")
|
||||||
older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n"
|
older := strings.Repeat("from node-b ", MemoryMostBytes/12-100) + "\n"
|
||||||
newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n"
|
newer := strings.Repeat("from node-d ", MemoryMostBytes/12-100) + "\n"
|
||||||
g14.write(t, project, "big.md", older, c.now.Add(time.Minute))
|
nodeB.write(t, project, "big.md", older, c.now.Add(time.Minute))
|
||||||
shanks.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
|
nodeD.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
|
||||||
c.now = c.now.Add(3 * time.Minute)
|
c.now = c.now.Add(3 * time.Minute)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
beside := OlderBeside("big.md", "g14")
|
beside := OlderBeside("big.md", "node-b")
|
||||||
if beside != "big.older-from-g14.md" {
|
if beside != "big.older-from-node-b.md" {
|
||||||
t.Fatalf("the older is named %q", beside)
|
t.Fatalf("the older is named %q", beside)
|
||||||
}
|
}
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
for _, n := range []*memNode{nodeB, nodeD} {
|
||||||
if got, _ := n.read(t, project, "big.md"); got != newer {
|
if got, _ := n.read(t, project, "big.md"); got != newer {
|
||||||
t.Fatalf("%s's file does not hold the newer version", n.Node)
|
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)
|
t.Fatalf("%s does not hold the older version beside it", n.Node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
notices := append(append([]map[string]any{}, nodeB.notices...), nodeD.notices...)
|
||||||
if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") {
|
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)
|
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.
|
// Under the size limit, but too large once encoded: not carried, and said.
|
||||||
g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
|
nodeB.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if _, ok := shanks.read(t, project, "angles.md"); ok {
|
if _, ok := nodeD.read(t, project, "angles.md"); ok {
|
||||||
t.Fatal("a text too large for one value was carried")
|
t.Fatal("a text too large for one value was carried")
|
||||||
}
|
}
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
not, _ := nodeB.Status()["not carried"].(map[string]string)
|
||||||
if not[project+"/angles.md"] == "" {
|
if not[project+"/angles.md"] == "" {
|
||||||
t.Fatalf("it is not listed as not carried: %v", not)
|
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
|
// 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.
|
// written for it.
|
||||||
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
|
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
root := filepath.Dir(filepath.Dir(shanks.Home))
|
root := filepath.Dir(filepath.Dir(nodeD.Home))
|
||||||
for _, bad := range []struct{ project, path string }{
|
for _, bad := range []struct{ project, path string }{
|
||||||
{"../../../escaped", "x.md"}, {"..", "x.md"}, {"a/b", "x.md"}, {"", "x.md"},
|
{"../../../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"},
|
{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)
|
state.pump(t)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||||
if err == nil && !d.IsDir() {
|
if err == nil && !d.IsDir() {
|
||||||
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
|
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
|
||||||
@@ -748,21 +748,21 @@ func TestWhatCannotBeReadIsNeverTakenForGone(t *testing.T) {
|
|||||||
if os.Geteuid() == 0 {
|
if os.Geteuid() == 0 {
|
||||||
t.Skip("root reads a file whatever its mode")
|
t.Skip("root reads a file whatever its mode")
|
||||||
}
|
}
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "index\n")
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "notes/deep.md", "deep\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "notes/deep.md", "deep\n")
|
||||||
file, folder := g14.path(project, "MEMORY.md"), filepath.Dir(g14.path(project, "notes/deep.md"))
|
file, folder := nodeB.path(project, "MEMORY.md"), filepath.Dir(nodeB.path(project, "notes/deep.md"))
|
||||||
_ = os.Chmod(file, 0)
|
_ = os.Chmod(file, 0)
|
||||||
_ = os.Chmod(folder, 0)
|
_ = os.Chmod(folder, 0)
|
||||||
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
|
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
|
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
|
||||||
if _, ok := shanks.read(t, project, f); !ok {
|
if _, ok := nodeD.read(t, project, f); !ok {
|
||||||
t.Errorf("%s, unreadable on g14, was removed on shanks", f)
|
t.Errorf("%s, unreadable on node-b, was removed on node-d", f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
not, _ := nodeB.Status()["not carried"].(map[string]string)
|
||||||
if len(not) == 0 {
|
if len(not) == 0 {
|
||||||
t.Fatalf("nothing is said as not carried: %v", not)
|
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"
|
token := "ghp_" + strings.Repeat("A1b2C3d4", 5) + "\n"
|
||||||
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
|
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
|
||||||
for _, text := range []string{token, big} {
|
for _, text := range []string{token, big} {
|
||||||
// The removal path: removed on g14, changed on shanks to what is never carried.
|
// The removal path: removed on node-b, changed on node-d to what is never carried.
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "note.md", "v1\n")
|
||||||
_ = os.Remove(g14.path(project, "note.md"))
|
_ = os.Remove(nodeB.path(project, "note.md"))
|
||||||
shanks.write(t, project, "note.md", text, c.now.Add(time.Minute))
|
nodeD.write(t, project, "note.md", text, c.now.Add(time.Minute))
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if state.holds(text[:20]) {
|
if state.holds(text[:20]) {
|
||||||
t.Errorf("the removal path put text that is never carried into the state")
|
t.Errorf("the removal path put text that is never carried into the state")
|
||||||
}
|
}
|
||||||
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
if got, _ := nodeD.read(t, project, "note.md"); got != text {
|
||||||
t.Errorf("the change on shanks was not kept")
|
t.Errorf("the change on node-d was not kept")
|
||||||
}
|
}
|
||||||
// The conflict path: changed on g14, and on shanks to what is never carried.
|
// The conflict path: changed on node-b, and on node-d to what is never carried.
|
||||||
state, c, g14, shanks = twoNodes(t)
|
state, c, nodeB, nodeD = twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "note.md", "v1\n")
|
||||||
g14.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
nodeB.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))
|
nodeD.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if state.holds(text[:20]) {
|
if state.holds(text[:20]) {
|
||||||
t.Errorf("the conflict path put text that is never carried into the state")
|
t.Errorf("the conflict path put text that is never carried into the state")
|
||||||
}
|
}
|
||||||
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
if got, _ := nodeD.read(t, project, "note.md"); got != text {
|
||||||
t.Errorf("shanks's own version was not kept as it was")
|
t.Errorf("node-d's own version was not kept as it was")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,24 +819,24 @@ func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
|
|||||||
if os.Geteuid() == 0 {
|
if os.Geteuid() == 0 {
|
||||||
t.Skip("root writes a folder whatever its mode")
|
t.Skip("root writes a folder whatever its mode")
|
||||||
}
|
}
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "base\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "base\n")
|
||||||
g14.write(t, project, "MEMORY.md", "base\nfrom g14\n", c.now.Add(time.Minute))
|
nodeB.write(t, project, "MEMORY.md", "base\nfrom node-b\n", c.now.Add(time.Minute))
|
||||||
shanks.write(t, project, "MEMORY.md", "base\nfrom shanks\n", c.now.Add(2*time.Minute))
|
nodeD.write(t, project, "MEMORY.md", "base\nfrom node-d\n", c.now.Add(2*time.Minute))
|
||||||
folder := filepath.Dir(shanks.path(project, "MEMORY.md"))
|
folder := filepath.Dir(nodeD.path(project, "MEMORY.md"))
|
||||||
_ = os.Chmod(folder, 0o500)
|
_ = os.Chmod(folder, 0o500)
|
||||||
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
|
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
_ = shanks.Look()
|
_ = nodeD.Look()
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
}
|
}
|
||||||
if state.holds("<!-- novox mesh") {
|
if state.holds("<!-- novox mesh") {
|
||||||
t.Fatal("a merge that could not be written here reached the state")
|
t.Fatal("a merge that could not be written here reached the state")
|
||||||
}
|
}
|
||||||
unwritten := 0
|
unwritten := 0
|
||||||
for _, n := range shanks.notices {
|
for _, n := range nodeD.notices {
|
||||||
if strings.HasSuffix(n["key"].(string), ".unwritten") {
|
if strings.HasSuffix(n["key"].(string), ".unwritten") {
|
||||||
unwritten++
|
unwritten++
|
||||||
}
|
}
|
||||||
@@ -845,13 +845,13 @@ func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
|
|||||||
t.Fatalf("%d warnings that the file could not be written", unwritten)
|
t.Fatalf("%d warnings that the file could not be written", unwritten)
|
||||||
}
|
}
|
||||||
_ = os.Chmod(folder, 0o700)
|
_ = os.Chmod(folder, 0o700)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
for _, n := range []*memNode{nodeB, nodeD} {
|
||||||
got, _ := n.read(t, project, "MEMORY.md")
|
got, _ := n.read(t, project, "MEMORY.md")
|
||||||
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from g14") || !strings.Contains(got, "from shanks") {
|
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from node-b") || !strings.Contains(got, "from node-d") {
|
||||||
t.Fatalf("%s does not hold both versions once:\n%s", n.Node, got)
|
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
|
// 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.
|
// not UTF-8 is not carried.
|
||||||
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
|
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
_ = state
|
_ = state
|
||||||
g14.write(t, project, "a.md", "read\n", c.now)
|
nodeB.write(t, project, "a.md", "read\n", c.now)
|
||||||
g14.write(t, project, "a.md", "changed meanwhile\n", c.now)
|
nodeB.write(t, project, "a.md", "changed meanwhile\n", c.now)
|
||||||
err := g14.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
|
err := nodeB.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
|
||||||
if !errors.Is(err, errChangedHere) {
|
if !errors.Is(err, errChangedHere) {
|
||||||
t.Fatalf("written over a change: %v", err)
|
t.Fatalf("written over a change: %v", err)
|
||||||
}
|
}
|
||||||
if got, _ := g14.read(t, project, "a.md"); got != "changed meanwhile\n" {
|
if got, _ := nodeB.read(t, project, "a.md"); got != "changed meanwhile\n" {
|
||||||
t.Fatalf("the change was replaced: %q", got)
|
t.Fatalf("the change was replaced: %q", got)
|
||||||
}
|
}
|
||||||
g14.write(t, project, "latin1.md", "caf\xe9\n", c.now)
|
nodeB.write(t, project, "latin1.md", "caf\xe9\n", c.now)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
if _, ok := shanks.read(t, project, "latin1.md"); ok {
|
if _, ok := nodeD.read(t, project, "latin1.md"); ok {
|
||||||
t.Fatal("text that is not UTF-8 was carried")
|
t.Fatal("text that is not UTF-8 was carried")
|
||||||
}
|
}
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
not, _ := nodeB.Status()["not carried"].(map[string]string)
|
||||||
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
|
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
|
||||||
t.Fatalf("it is not said why: %v", not)
|
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
|
// §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.
|
// its own: the removed file does not come back.
|
||||||
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
|
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "old.md", "v1\n")
|
carriedOnBoth(t, state, c, nodeB, nodeD, "old.md", "v1\n")
|
||||||
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
state.watchers = []*MemoryCarrier{nodeB.MemoryCarrier}
|
||||||
c.now = c.now.Add(time.Minute)
|
c.now = c.now.Add(time.Minute)
|
||||||
g14.write(t, project, "old.md", "v2\n", c.now)
|
nodeB.write(t, project, "old.md", "v2\n", c.now)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
_ = os.Remove(g14.path(project, "old.md"))
|
_ = os.Remove(nodeB.path(project, "old.md"))
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
state.watchers = append(state.watchers, nodeD.MemoryCarrier)
|
||||||
state.replay(t, shanks)
|
state.replay(t, nodeD)
|
||||||
shanks.look(t)
|
nodeD.look(t)
|
||||||
state.pump(t)
|
state.pump(t)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
if _, ok := shanks.read(t, project, "old.md"); ok {
|
if _, ok := nodeD.read(t, project, "old.md"); ok {
|
||||||
t.Fatal("the removal did not reach the node that was away")
|
t.Fatal("the removal did not reach the node that was away")
|
||||||
}
|
}
|
||||||
if _, ok := g14.read(t, project, "old.md"); ok {
|
if _, ok := nodeB.read(t, project, "old.md"); ok {
|
||||||
t.Fatal("the removed file came back")
|
t.Fatal("the removed file came back")
|
||||||
}
|
}
|
||||||
if !holdsText(shanks.copies(t), "v1\n") {
|
if !holdsText(nodeD.copies(t), "v1\n") {
|
||||||
t.Fatal("the version removed on the node that was away was not kept")
|
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;
|
// Issue 367, seen live: the runtime hands a watch its current values one at a time and gives them all ten seconds;
|
||||||
// shanks's handler read the state and waited for its look for each of a few hundred values, so the watch never
|
// 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 g14 reached it. The handler now only notes a change: it returns at once while a
|
// started and nothing written on node-b 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.
|
// look holds the carrier and while the state is slow to answer, and Drain applies what it noted.
|
||||||
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
|
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
|
||||||
state, c, g14, shanks := twoNodes(t)
|
state, c, nodeB, nodeD := twoNodes(t)
|
||||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
nodeB.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||||
g14.look(t)
|
nodeB.look(t)
|
||||||
state.mu.Lock()
|
state.mu.Lock()
|
||||||
state.pending = nil
|
state.pending = nil
|
||||||
state.mu.Unlock()
|
state.mu.Unlock()
|
||||||
slow := slowState{memState: state, gate: make(chan struct{})}
|
slow := slowState{memState: state, gate: make(chan struct{})}
|
||||||
shanks.Store = slow
|
nodeD.Store = slow
|
||||||
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
||||||
raw, rev, _, _ := state.Get(key)
|
raw, rev, _, _ := state.Get(key)
|
||||||
shanks.mu.Lock() // a look running
|
nodeD.mu.Lock() // a look running
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
for i := 0; i < 400; i++ {
|
for i := 0; i < 400; i++ {
|
||||||
go func() { done <- shanks.Take(key, "put", raw, rev) }()
|
go func() { done <- nodeD.Take(key, "put", raw, rev) }()
|
||||||
}
|
}
|
||||||
for i := 0; i < 400; i++ {
|
for i := 0; i < 400; i++ {
|
||||||
select {
|
select {
|
||||||
@@ -957,12 +957,12 @@ func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
|
|||||||
if took := time.Since(start); took > time.Second {
|
if took := time.Since(start); took > time.Second {
|
||||||
t.Fatalf("400 values took %s to hand over", took)
|
t.Fatalf("400 values took %s to hand over", took)
|
||||||
}
|
}
|
||||||
shanks.mu.Unlock()
|
nodeD.mu.Unlock()
|
||||||
close(slow.gate)
|
close(slow.gate)
|
||||||
if err := shanks.Drain(); err != nil {
|
if err := nodeD.Drain(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
t.Fatalf("what the watch noted was not applied: %q", got)
|
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{
|
Input: map[string]any{
|
||||||
"scope": str("mesh (every node) or node (the nodes given, or this one)"),
|
"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"),
|
"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-g14"),
|
"name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-node-b"),
|
||||||
"text": str("the section's text, markdown, as every session will read it"),
|
"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"},
|
"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"}}},
|
}, "required": []string{"label", "means"}}},
|
||||||
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
|
"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'"),
|
"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@g14"),
|
"by": str("required: your session's name, as who@node or who, e.g. fix-589@node-b"),
|
||||||
"expires_minutes": map[string]any{"type": "integer",
|
"expires_minutes": map[string]any{"type": "integer",
|
||||||
"description": "minutes until it expires, 5 to 1430; 60 when absent"},
|
"description": "minutes until it expires, 5 to 1430; 60 when absent"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ type router struct {
|
|||||||
|
|
||||||
func newRouter() *router {
|
func newRouter() *router {
|
||||||
return &router{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{},
|
return &router{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{},
|
||||||
kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "novox",
|
kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "node-c",
|
||||||
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
|
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 {
|
func proposeHandover(t *testing.T, p Paths, store *memProposals, r *router) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover}, store, r, t0)
|
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Text: handover}, store, r, t0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ func renderedInstructions(t *testing.T, p Paths) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T) {
|
func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
id := proposeHandover(t, p, store, r)
|
id := proposeHandover(t, p, store, r)
|
||||||
|
|
||||||
@@ -153,10 +153,10 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
|||||||
asks.Glance(a.Explanation) != nil {
|
asks.Glance(a.Explanation) != nil {
|
||||||
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
|
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
|
||||||
}
|
}
|
||||||
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
if a.Headline != "Add Claude instructions 'handoff-from-node-b'?" {
|
||||||
t.Errorf("headline: %q", a.Headline)
|
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 g14 at ") {
|
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on node-b at ") {
|
||||||
t.Errorf("a short text is not shown whole: %q", a.Details)
|
t.Errorf("a short text is not shown whole: %q", a.Details)
|
||||||
}
|
}
|
||||||
if len(a.Options) != 2 {
|
if len(a.Options) != 2 {
|
||||||
@@ -180,26 +180,26 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApprovedTextReachesEveryNodeOnlyOnAWarrantEachNodeProves(t *testing.T) {
|
func TestApprovedTextReachesEveryNodeOnlyOnAWarrantEachNodeProves(t *testing.T) {
|
||||||
g14, _ := node(t, "g14")
|
nodeB, _ := node(t, "node-b")
|
||||||
shanks, _ := node(t, "shanks")
|
nodeD, _ := node(t, "node-d")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
id := proposeHandover(t, g14, store, r)
|
id := proposeHandover(t, nodeB, store, r)
|
||||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||||
rec := &recorded{}
|
rec := &recorded{}
|
||||||
changed, said, err := Reconcile(shanks, store, r, rec.record, t0.Add(time.Hour+time.Minute))
|
changed, said, err := Reconcile(nodeD, store, r, rec.record, t0.Add(time.Hour+time.Minute))
|
||||||
if err != nil || !changed {
|
if err != nil || !changed {
|
||||||
t.Fatalf("shanks did not take the approved text: %v %v", err, said)
|
t.Fatalf("node-d did not take the approved text: %v %v", err, said)
|
||||||
}
|
}
|
||||||
got := renderedInstructions(t, shanks)
|
got := renderedInstructions(t, nodeD)
|
||||||
if !strings.Contains(got, "## Instructions the operator approved") || !strings.Contains(got, "### handoff-from-g14") ||
|
if !strings.Contains(got, "## Instructions the operator approved") || !strings.Contains(got, "### handoff-from-node-b") ||
|
||||||
!strings.Contains(got, "Do not merge it before the gate is green.") {
|
!strings.Contains(got, "Do not merge it before the gate is green.") {
|
||||||
t.Fatalf("a new session on shanks does not read the text:\n%s", got)
|
t.Fatalf("a new session on node-d does not read the text:\n%s", got)
|
||||||
}
|
}
|
||||||
if len(rec.asks) != 1 || rec.asks[0] != id {
|
if len(rec.asks) != 1 || rec.asks[0] != id {
|
||||||
t.Errorf("the warrant was not handed to the hand-act log: %v", rec.asks)
|
t.Errorf("the warrant was not handed to the hand-act log: %v", rec.asks)
|
||||||
}
|
}
|
||||||
// Heard again, or replayed: decided once, recorded once.
|
// Heard again, or replayed: decided once, recorded once.
|
||||||
if changed, _, _ := Reconcile(shanks, store, r, rec.record, t0.Add(2*time.Hour)); changed || len(rec.asks) != 1 {
|
if changed, _, _ := Reconcile(nodeD, 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)
|
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 {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
p, _ := node(t, "shanks")
|
p, _ := node(t, "node-d")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
id := proposeHandover(t, p, store, r)
|
id := proposeHandover(t, p, store, r)
|
||||||
c.tamper(r, store, id)
|
c.tamper(r, store, id)
|
||||||
@@ -278,7 +278,7 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.T) {
|
func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.T) {
|
||||||
p, _ := node(t, "shanks")
|
p, _ := node(t, "node-d")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
id := proposeHandover(t, p, store, r)
|
id := proposeHandover(t, p, store, r)
|
||||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
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 {
|
if _, _, err := Reconcile(p, store, r, nil, t0.Add(2*time.Hour)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-node-b") {
|
||||||
t.Fatal("deleting the bus record removed an approved section")
|
t.Fatal("deleting the bus record removed an approved section")
|
||||||
}
|
}
|
||||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Remove: true}, store, r, t0.Add(3*time.Hour))
|
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Remove: true}, store, r, t0.Add(3*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
rid := out["proposed"].(string)
|
rid := out["proposed"].(string)
|
||||||
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
|
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-node-b' from its next start." {
|
||||||
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
||||||
}
|
}
|
||||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-node-b") {
|
||||||
t.Fatal("proposing a removal removed it")
|
t.Fatal("proposing a removal removed it")
|
||||||
}
|
}
|
||||||
r.close(r.chose(rid, OptionApprove, t0.Add(4*time.Hour)))
|
r.close(r.chose(rid, OptionApprove, t0.Add(4*time.Hour)))
|
||||||
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(4*time.Hour)); !changed {
|
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(4*time.Hour)); !changed {
|
||||||
t.Fatal("the approved removal changed nothing")
|
t.Fatal("the approved removal changed nothing")
|
||||||
}
|
}
|
||||||
if strings.Contains(renderedInstructions(t, p), "handoff-from-g14") {
|
if strings.Contains(renderedInstructions(t, p), "handoff-from-node-b") {
|
||||||
t.Fatal("the approved removal left the section")
|
t.Fatal("the approved removal left the section")
|
||||||
}
|
}
|
||||||
// The first approval, replayed into the bus again, brings nothing back: it was decided here.
|
// The first approval, replayed into the bus again, brings nothing back: it was decided here.
|
||||||
r2 := r.records[id]
|
r2 := r.records[id]
|
||||||
_ = store.Put(id, Proposal{ID: id, Action: ActionAdd, Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
|
_ = store.Put(id, Proposal{ID: id, Action: ActionAdd, Scope: ScopeMesh, Name: "handoff-from-node-b", Text: handover,
|
||||||
Digest: textDigest(handover), From: "g14", At: t0})
|
Digest: textDigest(handover), From: "node-b", At: t0})
|
||||||
r.records[id] = r2
|
r.records[id] = r2
|
||||||
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(5*time.Hour)); changed {
|
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(5*time.Hour)); changed {
|
||||||
t.Fatal("a replayed approval brought a removed section back")
|
t.Fatal("a replayed approval brought a removed section back")
|
||||||
@@ -321,22 +321,22 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
|
func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
|
||||||
ace, _ := node(t, "ace")
|
nodeA, _ := node(t, "node-a")
|
||||||
shanks, _ := node(t, "shanks")
|
nodeD, _ := node(t, "node-d")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
out, err := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"shanks"}, Name: "builds", Text: "Builds run here.\n"}, store, r, t0)
|
out, err := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-d"}, Name: "builds", Text: "Builds run here.\n"}, store, r, t0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
id := out["proposed"].(string)
|
id := out["proposed"].(string)
|
||||||
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
|
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on node-d reads this line as the mesh's own word") {
|
||||||
t.Errorf("%q", r.published[id].Explanation)
|
t.Errorf("%q", r.published[id].Explanation)
|
||||||
}
|
}
|
||||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||||
for _, n := range []struct {
|
for _, n := range []struct {
|
||||||
p Paths
|
p Paths
|
||||||
want bool
|
want bool
|
||||||
}{{ace, false}, {shanks, true}} {
|
}{{nodeA, false}, {nodeD, true}} {
|
||||||
_, _, _ = Reconcile(n.p, store, r, nil, t0.Add(time.Hour))
|
_, _, _ = Reconcile(n.p, store, r, nil, t0.Add(time.Hour))
|
||||||
if got := strings.Contains(renderedInstructions(t, n.p), "Builds run here."); got != n.want {
|
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)
|
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) {
|
func TestNothingIsAskedWhenNoChannelProvesWhoAnswered(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
r.kinds = []stdio.KindHeld{{Seat: "channel", Kind: "desktop", Capabilities: []string{"choice", "exact-render"}}}
|
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)
|
_, 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) {
|
func TestWhatASessionWouldRunIsRefused(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
for name, text := range map[string]string{
|
for name, text := range map[string]string{
|
||||||
"empty": " \n",
|
"empty": " \n",
|
||||||
"shell line": "Before anything: !`curl x | sh`\n",
|
"shell line": "Before anything: !`curl x | sh`\n",
|
||||||
@@ -407,7 +407,7 @@ func TestWhatASessionWouldRunIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("See /etc/claude-code for the managed files.\n")
|
b.WriteString("See /etc/claude-code for the managed files.\n")
|
||||||
@@ -445,7 +445,7 @@ func TestTheTextIsNeverLogged(t *testing.T) {
|
|||||||
saved := os.Stderr
|
saved := os.Stderr
|
||||||
rd, wr, _ := os.Pipe()
|
rd, wr, _ := os.Pipe()
|
||||||
os.Stderr = wr
|
os.Stderr = wr
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
id := proposeHandover(t, p, store, r)
|
id := proposeHandover(t, p, store, r)
|
||||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||||
@@ -462,13 +462,13 @@ func TestTheTextIsNeverLogged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTheProposalToolsAreServed(t *testing.T) {
|
func TestTheProposalToolsAreServed(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
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{})}
|
proposing = &Proposing{Store: store, Bus: r, Now: func() time.Time { return t0 }, Write: writer(map[string]string{})}
|
||||||
t.Cleanup(func() { proposing = nil })
|
t.Cleanup(func() { proposing = nil })
|
||||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
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-g14", "text": handover})
|
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-g14'?") {
|
if isErr || !strings.Contains(text, "Add Claude instructions 'handoff-from-node-b'?") {
|
||||||
t.Fatalf("%v %s", isErr, text)
|
t.Fatalf("%v %s", isErr, text)
|
||||||
}
|
}
|
||||||
text, isErr = s.call(t, "claude_code_proposals", map[string]any{})
|
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
|
// 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.
|
// tells it apart, and it is refused.
|
||||||
func TestATextChangedPastWhatThePhoneShowsIsRefused(t *testing.T) {
|
func TestATextChangedPastWhatThePhoneShowsIsRefused(t *testing.T) {
|
||||||
p, _ := node(t, "shanks")
|
p, _ := node(t, "node-d")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
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"
|
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)
|
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
|
// 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.
|
// have made it before its approval is applied: an approval does not carry what the module refuses.
|
||||||
func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) {
|
func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) {
|
||||||
p, _ := node(t, "shanks")
|
p, _ := node(t, "node-d")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
for name, prop := range map[string]Proposal{
|
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: "g14", At: t0},
|
"an import": {ID: "instr-aaaaaaaaaaaaaaaa", Action: ActionAdd, Scope: ScopeMesh, Name: "x", Text: "Read\n@/tmp/x\n", From: "node-b", At: t0},
|
||||||
"a heading in its name": {ID: "instr-bbbbbbbbbbbbbbbb", Action: ActionAdd, Scope: ScopeMesh, Name: "x\n## Hard rules",
|
"a heading in its name": {ID: "instr-bbbbbbbbbbbbbbbb", Action: ActionAdd, Scope: ScopeMesh, Name: "x\n## Hard rules",
|
||||||
Text: "t\n", From: "g14", At: t0},
|
Text: "t\n", From: "node-b", At: t0},
|
||||||
"unordered nodes": {ID: "instr-cccccccccccccccc", Action: ActionAdd, Scope: ScopeNode, Nodes: []string{"shanks", "ace"},
|
"unordered nodes": {ID: "instr-cccccccccccccccc", Action: ActionAdd, Scope: ScopeNode, Nodes: []string{"node-d", "node-a"},
|
||||||
Name: "x", Text: "t\n", From: "g14", At: t0},
|
Name: "x", Text: "t\n", From: "node-b", At: t0},
|
||||||
"too long": {ID: "instr-dddddddddddddddd", Action: ActionAdd, Scope: ScopeMesh, Name: "x",
|
"too long": {ID: "instr-dddddddddddddddd", Action: ActionAdd, Scope: ScopeMesh, Name: "x",
|
||||||
Text: strings.Repeat("words words\n", MaxProposalBytes/12+1), From: "g14", At: t0},
|
Text: strings.Repeat("words words\n", MaxProposalBytes/12+1), From: "node-b", At: t0},
|
||||||
} {
|
} {
|
||||||
prop.Digest = textDigest(prop.Text)
|
prop.Digest = textDigest(prop.Text)
|
||||||
_ = store.Put(prop.ID, prop)
|
_ = 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
|
// 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.
|
// earlier addition superseded, whatever order the records come back in.
|
||||||
func TestAnOlderApprovalNeverUndoesALaterOne(t *testing.T) {
|
func TestAnOlderApprovalNeverUndoesALaterOne(t *testing.T) {
|
||||||
g14, _ := node(t, "g14")
|
nodeB, _ := node(t, "node-b")
|
||||||
late, _ := node(t, "shanks")
|
late, _ := node(t, "node-d")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
add := proposeHandover(t, g14, store, r)
|
add := proposeHandover(t, nodeB, store, r)
|
||||||
r.close(r.chose(add, OptionApprove, t0.Add(time.Hour)))
|
r.close(r.chose(add, OptionApprove, t0.Add(time.Hour)))
|
||||||
if _, _, err := Reconcile(g14, store, r, nil, t0.Add(time.Hour)); err != nil {
|
if _, _, err := Reconcile(nodeB, store, r, nil, t0.Add(time.Hour)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
out, err := Propose(g14, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Remove: true}, store, r, t0.Add(2*time.Hour))
|
out, err := Propose(nodeB, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Remove: true}, store, r, t0.Add(2*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if changed, _, _ := Reconcile(late, store, r, nil, t0.Add(5*time.Hour)); changed {
|
||||||
t.Fatal("the earlier addition, read after the removal, was applied")
|
t.Fatal("the earlier addition, read after the removal, was applied")
|
||||||
}
|
}
|
||||||
if strings.Contains(renderedInstructions(t, late), "handoff-from-g14") {
|
if strings.Contains(renderedInstructions(t, late), "handoff-from-node-b") {
|
||||||
t.Fatal("a removed section came back")
|
t.Fatal("a removed section came back")
|
||||||
}
|
}
|
||||||
if got := outcomeHere(t, late, add); !strings.Contains(got, "superseded") {
|
if got := outcomeHere(t, late, add); !strings.Contains(got, "superseded") {
|
||||||
@@ -611,7 +611,7 @@ func TestAnOlderApprovalNeverUndoesALaterOne(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAnUnreadableRecordIsNeverTakenForAnEmptyOne(t *testing.T) {
|
func TestAnUnreadableRecordIsNeverTakenForAnEmptyOne(t *testing.T) {
|
||||||
p, _ := node(t, "shanks")
|
p, _ := node(t, "node-d")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
id := proposeHandover(t, p, store, r)
|
id := proposeHandover(t, p, store, r)
|
||||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
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
|
// 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.
|
// or removes nothing of the first.
|
||||||
func TestANodeSectionIsNotReplacedFromOtherNodes(t *testing.T) {
|
func TestANodeSectionIsNotReplacedFromOtherNodes(t *testing.T) {
|
||||||
ace, _ := node(t, "ace")
|
nodeA, _ := node(t, "node-a")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
out, _ := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"ace"}, Name: "builds", Text: "On ace.\n"}, store, r, t0)
|
out, _ := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-a"}, Name: "builds", Text: "On node-a.\n"}, store, r, t0)
|
||||||
r.close(r.chose(out["proposed"].(string), OptionApprove, t0.Add(time.Hour)))
|
r.close(r.chose(out["proposed"].(string), OptionApprove, t0.Add(time.Hour)))
|
||||||
_, _, _ = Reconcile(ace, store, r, nil, t0.Add(time.Hour))
|
_, _, _ = Reconcile(nodeA, 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))
|
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))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if out["action"] != ActionAdd {
|
if out["action"] != ActionAdd {
|
||||||
t.Errorf("a section for other nodes was proposed as %v", out["action"])
|
t.Errorf("a section for other nodes was proposed as %v", out["action"])
|
||||||
}
|
}
|
||||||
if _, err := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"shanks"}, Name: "builds", Remove: true}, store, r, t0.Add(2*time.Hour)); err == nil {
|
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 ace")
|
t.Error("a removal for other nodes was taken for the section on node-a")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A module that only asks is issued no channel kinds (they come with a kinded bench a module uses or watches): an
|
// 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).
|
// empty list is no refusal, and the router, which judges, is asked (the live failure of 2026-10-10).
|
||||||
func TestAnAskerIssuedNoKindsLeavesTheJudgingToTheRouter(t *testing.T) {
|
func TestAnAskerIssuedNoKindsLeavesTheJudgingToTheRouter(t *testing.T) {
|
||||||
p, _ := node(t, "novox")
|
p, _ := node(t, "node-c")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
r.kinds = nil
|
r.kinds = nil
|
||||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, store, r, t0)
|
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.
|
// The router's refusal reaches the proposer at once, and discards the proposal on every node that hears it.
|
||||||
func TestTheRoutersRefusalIsSaidAndDiscards(t *testing.T) {
|
func TestTheRoutersRefusalIsSaidAndDiscards(t *testing.T) {
|
||||||
p, _ := node(t, "novox")
|
p, _ := node(t, "node-c")
|
||||||
store := &memProposals{m: map[string]json.RawMessage{}}
|
store := &memProposals{m: map[string]json.RawMessage{}}
|
||||||
r := newRouter()
|
r := newRouter()
|
||||||
r.kinds = nil
|
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.
|
// A proposal whose ask ended and that the router keeps no record of is discarded, never left waiting.
|
||||||
func TestAnAskTheRouterKeepsNoRecordOfIsDiscardedAfterItsTime(t *testing.T) {
|
func TestAnAskTheRouterKeepsNoRecordOfIsDiscardedAfterItsTime(t *testing.T) {
|
||||||
p, _ := node(t, "novox")
|
p, _ := node(t, "node-c")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
id := proposeHandover(t, p, store, r)
|
id := proposeHandover(t, p, store, r)
|
||||||
delete(r.records, id)
|
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.
|
// 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) {
|
func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
|
||||||
p, _ := node(t, "novox")
|
p, _ := node(t, "node-c")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
id := proposeHandover(t, p, store, r)
|
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 {
|
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
|
// 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.
|
// asked, so the warrant for it still holds after the change.
|
||||||
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
|
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
|
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Text: handover,
|
||||||
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
|
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
|||||||
by = who
|
by = who
|
||||||
}
|
}
|
||||||
if by == "" {
|
if by == "" {
|
||||||
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@g14")
|
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@node-b")
|
||||||
}
|
}
|
||||||
life := in.Expires
|
life := in.Expires
|
||||||
if life == 0 {
|
if life == 0 {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func mergeTonight() QuestionInput {
|
|||||||
},
|
},
|
||||||
Why: "it is reviewed and the checks are green",
|
Why: "it is reviewed and the checks are green",
|
||||||
OnExpiry: "I leave the pull request open",
|
OnExpiry: "I leave the pull request open",
|
||||||
By: "fix-589@g14",
|
By: "fix-589@node-b",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ func askMergeTonight(t *testing.T, p Paths, store *memQuestions, r *router, at t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := newMemQuestions(), newRouter()
|
store, r := newMemQuestions(), newRouter()
|
||||||
out, err := AskQuestion(p, mergeTonight(), store, r, t0)
|
out, err := AskQuestion(p, mergeTonight(), store, r, t0)
|
||||||
if err != nil {
|
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 {
|
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)
|
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
|
for _, want := range []string{"the agent session fix-589 on node-b", "performs nothing by itself"} {
|
||||||
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
|
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
|
||||||
t.Errorf("the details do not say %q:\n%s", want, a.Details)
|
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) {
|
func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := newMemQuestions(), newRouter()
|
store, r := newMemQuestions(), newRouter()
|
||||||
key := askMergeTonight(t, p, store, r, t0)
|
key := askMergeTonight(t, p, store, r, t0)
|
||||||
out, err := ReadAnswer(store, r, key, t0.Add(time.Minute))
|
out, err := ReadAnswer(store, r, key, t0.Add(time.Minute))
|
||||||
@@ -105,7 +105,7 @@ func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
|
func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := newMemQuestions(), newRouter()
|
store, r := newMemQuestions(), newRouter()
|
||||||
key := askMergeTonight(t, p, store, r, t0)
|
key := askMergeTonight(t, p, store, r, t0)
|
||||||
w := r.chose(key, "o1", t0.Add(time.Minute))
|
w := r.chose(key, "o1", t0.Add(time.Minute))
|
||||||
@@ -140,7 +140,7 @@ func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
for name, change := range map[string]func(*QuestionInput){
|
for name, change := range map[string]func(*QuestionInput){
|
||||||
"one option": func(in *QuestionInput) { in.Options = in.Options[:1] },
|
"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]) },
|
"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 = "" },
|
"an option unsaid": func(in *QuestionInput) { in.Options[1].Means = "" },
|
||||||
"too short": func(in *QuestionInput) { in.Expires = time.Minute },
|
"too short": func(in *QuestionInput) { in.Expires = time.Minute },
|
||||||
"too long": func(in *QuestionInput) { in.Expires = 2 * 24 * time.Hour },
|
"too long": func(in *QuestionInput) { in.Expires = 2 * 24 * time.Hour },
|
||||||
"another node": func(in *QuestionInput) { in.By = "fix-589@shanks" },
|
"another node": func(in *QuestionInput) { in.By = "fix-589@node-d" },
|
||||||
"an invisible char": func(in *QuestionInput) { in.Context += "\u200b" },
|
"an invisible char": func(in *QuestionInput) { in.Context += "\u200b" },
|
||||||
"invisible label": func(in *QuestionInput) { in.Options[0].Label = "Merge\u200bnow" },
|
"invisible label": func(in *QuestionInput) { in.Options[0].Label = "Merge\u200bnow" },
|
||||||
"invisible means": func(in *QuestionInput) { in.Options[1].Means += "\u202e" },
|
"invisible means": func(in *QuestionInput) { in.Options[1].Means += "\u202e" },
|
||||||
@@ -182,7 +182,7 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
|
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := newMemQuestions(), newRouter()
|
store, r := newMemQuestions(), newRouter()
|
||||||
first := askMergeTonight(t, p, store, r, t0)
|
first := askMergeTonight(t, p, store, r, t0)
|
||||||
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) {
|
func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemQuestions()
|
store := newMemQuestions()
|
||||||
refusing := &questionRefusingRouter{router: newRouter(), store: store}
|
refusing := &questionRefusingRouter{router: newRouter(), store: store}
|
||||||
within, every := RouterAnswersWithin, RouterAnswersEvery
|
within, every := RouterAnswersWithin, RouterAnswersEvery
|
||||||
@@ -244,7 +244,7 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
|
func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := newMemQuestions(), newRouter()
|
store, r := newMemQuestions(), newRouter()
|
||||||
key := askMergeTonight(t, p, store, r, t0)
|
key := askMergeTonight(t, p, store, r, t0)
|
||||||
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
|
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) {
|
func TestTheQuestionToolsAreServed(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
r := newRouter()
|
r := newRouter()
|
||||||
questions := newMemQuestions()
|
questions := newMemQuestions()
|
||||||
proposing = &Proposing{Store: &memProposals{m: map[string]json.RawMessage{}}, Bus: r, Now: func() time.Time { return t0 },
|
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": "Merge now", "means": "I merge it", "recommended": true},
|
||||||
map[string]any{"label": "Wait", "means": "I leave it open"},
|
map[string]any{"label": "Wait", "means": "I leave it open"},
|
||||||
},
|
},
|
||||||
"why": "it is reviewed", "on_expiry": "I leave it open", "by": "fix-589@g14", "expires_minutes": 30,
|
"why": "it is reviewed", "on_expiry": "I leave it open", "by": "fix-589@node-b", "expires_minutes": 30,
|
||||||
})
|
})
|
||||||
if isErr || !strings.Contains(text, `"key"`) {
|
if isErr || !strings.Contains(text, `"key"`) {
|
||||||
t.Fatalf("%v %s", isErr, text)
|
t.Fatalf("%v %s", isErr, text)
|
||||||
@@ -292,7 +292,7 @@ func TestAReadWaitsNoLongerThanItsBound(t *testing.T) {
|
|||||||
if AnswerWaitsAtMost != 60*time.Second {
|
if AnswerWaitsAtMost != 60*time.Second {
|
||||||
t.Fatalf("a read may wait %s; ADR 0332 says 60 s", AnswerWaitsAtMost)
|
t.Fatalf("a read may wait %s; ADR 0332 says 60 s", AnswerWaitsAtMost)
|
||||||
}
|
}
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := newMemQuestions(), newRouter()
|
store, r := newMemQuestions(), newRouter()
|
||||||
key := askMergeTonight(t, p, store, r, t0)
|
key := askMergeTonight(t, p, store, r, t0)
|
||||||
most, every := AnswerWaitsAtMost, AnswerLooksEvery
|
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.
|
// The edges of ADR 0332 §2 that are allowed: a 60-character headline and 1430 minutes.
|
||||||
func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
|
func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
in := mergeTonight()
|
in := mergeTonight()
|
||||||
in.Headline = strings.Repeat("x", 59) + "?"
|
in.Headline = strings.Repeat("x", 59) + "?"
|
||||||
in.Expires = 1430 * time.Minute
|
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.
|
// 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) {
|
func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
for name, change := range map[string]func(*asks.Warrant){
|
for name, change := range map[string]func(*asks.Warrant){
|
||||||
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
|
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
|
||||||
"another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" },
|
"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.
|
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
|
||||||
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
|
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
|
||||||
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
|
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
|
||||||
nodes := []string{"g14", "shanks", "ace"}
|
nodes := []string{"node-b", "node-d", "node-a"}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errs := make([]error, len(nodes))
|
errs := make([]error, len(nodes))
|
||||||
for i, name := range 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.
|
// A slot that cannot be read refuses the question: the cap fails closed.
|
||||||
func TestTheCapFailsClosed(t *testing.T) {
|
func TestTheCapFailsClosed(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := newMemQuestions(), newRouter()
|
store, r := newMemQuestions(), newRouter()
|
||||||
store.failGet = "slot-"
|
store.failGet = "slot-"
|
||||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
|
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
|
// 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.
|
// 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) {
|
func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := newMemQuestions(), newRouter()
|
store, r := newMemQuestions(), newRouter()
|
||||||
first := askMergeTonight(t, p, store, r, t0)
|
first := askMergeTonight(t, p, store, r, t0)
|
||||||
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
|
// 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.
|
// 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) {
|
func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store, r := newMemQuestions(), newRouter()
|
store, r := newMemQuestions(), newRouter()
|
||||||
// Two questions that ended: their slots are free to take again.
|
// 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)} {
|
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)})
|
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(time.Minute)})
|
||||||
}
|
}
|
||||||
shanks, _ := node(t, "shanks")
|
nodeD, _ := node(t, "node-d")
|
||||||
other := newRouter()
|
other := newRouter()
|
||||||
store.afterGet = func(key string) {
|
store.afterGet = func(key string) {
|
||||||
if !strings.HasPrefix(key, "slot-") {
|
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.
|
// 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",
|
q := Question{ID: newQuestionID(), Headline: "Another", Context: "c", Why: "w", OnExpiry: "e", By: "other",
|
||||||
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
|
From: nodeD.Node, At: t0, Expires: t0.Add(time.Hour),
|
||||||
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
|
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
|
||||||
_ = store.Put(q.ID, q)
|
_ = store.Put(q.ID, q)
|
||||||
_ = other.Publish(asks.AskSubject(Module), q.Ask(), q.ID)
|
_ = other.Publish(asks.AskSubject(Module), q.Ask(), q.ID)
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) {
|
|||||||
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
|
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
|
||||||
waitsDir := t.TempDir()
|
waitsDir := t.TempDir()
|
||||||
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
|
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
|
||||||
idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{
|
idx := NodeIndex{Node: "node-b", At: lastCall, Names: []NodeIndexEntry{
|
||||||
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
|
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
|
||||||
}}
|
}}
|
||||||
raw, err := indented(idx)
|
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") {
|
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)
|
t.Fatalf("the name and its count are not listed:\n%s", msg)
|
||||||
}
|
}
|
||||||
want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}`
|
want := `claude_code_wait {"op":"adopt","by":"fix-1629@node-b","session":"sess-brand-new"}`
|
||||||
if !strings.Contains(msg, want) {
|
if !strings.Contains(msg, want) {
|
||||||
t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", 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.
|
// 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) {
|
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0)
|
id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0)
|
||||||
if !strings.HasPrefix(id, WaitPrefix) {
|
if !strings.HasPrefix(id, WaitPrefix) {
|
||||||
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
|
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
|
||||||
}
|
}
|
||||||
out, err := ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(time.Minute))
|
out, err := ListWaits(p, "sess-1", "fix-1629@node-b", store, t0.Add(time.Minute))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 {
|
||||||
t.Fatalf("%+v", open)
|
t.Fatalf("%+v", open)
|
||||||
}
|
}
|
||||||
out, err = ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(11*time.Minute))
|
out, err = ListWaits(p, "sess-1", "fix-1629@node-b", store, t0.Add(11*time.Minute))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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
|
// 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.
|
// it from the open list.
|
||||||
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
|
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0)
|
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") {
|
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
|
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
|
||||||
// and the write time.
|
// and the write time.
|
||||||
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
|
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0)
|
id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0)
|
||||||
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
|
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||||
if err != nil {
|
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
|
// 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.
|
// after a /clear that keeps calling with the same session id; a different name lists none of them.
|
||||||
func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
|
func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
addApproveWait(t, p, store, "fix-1629", "sess-4", t0)
|
addApproveWait(t, p, store, "fix-1629", "sess-4", t0)
|
||||||
|
|
||||||
// A restart: a fresh Waiting over the same store reads the same waits.
|
// 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.
|
// A different name, same node: none of them.
|
||||||
out, err = ListWaits(p, "sess-other", "fix-other@g14", store, t0.Add(2*time.Minute))
|
out, err = ListWaits(p, "sess-other", "fix-other@node-b", store, t0.Add(2*time.Minute))
|
||||||
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
||||||
t.Fatalf("another name saw them: %v %v", out, err)
|
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
|
// 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.
|
// address, and more than 50 open waits per name, naming the argument.
|
||||||
func TestAddRefusesOutsideItsBounds(t *testing.T) {
|
func TestAddRefusesOutsideItsBounds(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
base := func() WaitInput {
|
base := func() WaitInput {
|
||||||
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
|
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
|
// 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.
|
// same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key.
|
||||||
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
|
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
for _, bad := range []string{"", "a/b", "../x"} {
|
for _, bad := range []string{"", "a/b", "../x"} {
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
if _, err := ListWaits(p, bad, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
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.
|
// 1629/AC6: the tool is served through the module's own dispatch.
|
||||||
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||||
t.Cleanup(func() { waiting = nil })
|
t.Cleanup(func() { waiting = nil })
|
||||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
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@g14",
|
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@node-b",
|
||||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||||
if isErr {
|
if isErr {
|
||||||
t.Fatalf("add: %s", text)
|
t.Fatalf("add: %s", text)
|
||||||
@@ -288,12 +288,12 @@ func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
|||||||
t.Fatalf("no id answered: %s", text)
|
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@g14"})
|
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@node-b"})
|
||||||
if isErr || !strings.Contains(text, id) {
|
if isErr || !strings.Contains(text, id) {
|
||||||
t.Fatalf("list: %v %s", isErr, text)
|
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@g14",
|
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@node-b",
|
||||||
"id": id, "outcome": "approved"})
|
"id": id, "outcome": "approved"})
|
||||||
if isErr {
|
if isErr {
|
||||||
t.Fatalf("done: %s", text)
|
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
|
// 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.
|
// a separate adopt first.
|
||||||
func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
|
func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0)
|
id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0)
|
||||||
out, err := ListWaits(p, "sess-a", "fix-1629@g14", store, t0)
|
out, err := ListWaits(p, "sess-a", "fix-1629@node-b", store, t0)
|
||||||
if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id {
|
if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id {
|
||||||
t.Fatalf("%v %v", out, err)
|
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
|
// 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.
|
// name (any op) is refused, naming the session that holds it and when it last called.
|
||||||
func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
|
func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
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"},
|
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
|
// 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.
|
// mirrored file of the old session id is emptied and the new one holds the waits, with no delay.
|
||||||
func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
|
func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||||
|
|
||||||
out, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute))
|
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 —
|
// ADR 0490 §3: adopt of a name bound to another id always takes it over, however recently that id called —
|
||||||
// no bound protects it.
|
// no bound protects it.
|
||||||
func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
|
func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0)
|
addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0)
|
||||||
// sess-fresh calls again right before being adopted away from.
|
// 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 {
|
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
|
// 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.
|
// another session, naming it.
|
||||||
func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
|
func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||||
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
||||||
t.Fatal(err)
|
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
|
// ADR 0490 §2: the node-wide index lists every name on this node with an open wait, for the SessionStart
|
||||||
// hook to read directly.
|
// hook to read directly.
|
||||||
func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
|
func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
addApproveWait(t, p, store, "fix-1629", "sess-x", t0)
|
addApproveWait(t, p, store, "fix-1629", "sess-x", t0)
|
||||||
addApproveWait(t, p, store, "fix-1744", "sess-y", t0)
|
addApproveWait(t, p, store, "fix-1744", "sess-y", t0)
|
||||||
if _, err := DoneWait(p, "sess-x", "fix-1629@g14", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
|
if _, err := DoneWait(p, "sess-x", "fix-1629@node-b", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
|
||||||
store, t0.Add(time.Minute)); err != nil {
|
store, t0.Add(time.Minute)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -426,7 +426,7 @@ func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if idx.Node != "g14" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
|
if idx.Node != "node-b" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
|
||||||
idx.Names[0].CurrentSession != "sess-y" {
|
idx.Names[0].CurrentSession != "sess-y" {
|
||||||
t.Fatalf("%+v", idx)
|
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).
|
// claude_code_wait op=adopt, served through the module's own dispatch (1629/AC6, ADR 0490).
|
||||||
func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
|
func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||||
t.Cleanup(func() { waiting = nil })
|
t.Cleanup(func() { waiting = nil })
|
||||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
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@g14",
|
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@node-b",
|
||||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||||
if isErr {
|
if isErr {
|
||||||
t.Fatalf("add: %s", text)
|
t.Fatalf("add: %s", text)
|
||||||
}
|
}
|
||||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@g14"})
|
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@node-b"})
|
||||||
if isErr || !strings.Contains(text, "sess-old") {
|
if isErr || !strings.Contains(text, "sess-old") {
|
||||||
t.Fatalf("adopt: %v %s", isErr, text)
|
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
|
// 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.
|
// names' waits in its one mirrored file, not only the name an op last acted on.
|
||||||
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
|
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
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
|
// 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.
|
// file holding the other name's waits — it is never emptied wholesale.
|
||||||
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
|
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
||||||
|
|
||||||
if _, err := AdoptName(p, "fix-y@g14", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
|
if _, err := AdoptName(p, "fix-y@node-b", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
|
||||||
t.Fatal(err)
|
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
|
// 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.
|
// fresh three-strikes count starts under the new binding.
|
||||||
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
|
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||||
waitsDir := WaitsDirPath(p)
|
waitsDir := WaitsDirPath(p)
|
||||||
if err := writeRefusalCounters(waitsDir, "sess-old", map[string]int{"w-1": 2}); err != nil {
|
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
|
// 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.
|
// mismatched session; it answers the name's waits and who currently holds it.
|
||||||
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
|
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
|
||||||
p, _ := node(t, "g14")
|
p, _ := node(t, "node-b")
|
||||||
store := newMemWaits()
|
store := newMemWaits()
|
||||||
by := "fix-1629@g14"
|
by := "fix-1629@node-b"
|
||||||
|
|
||||||
// An unbound name: list answers no waits, and binds nothing (a later add from a different session still
|
// 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).
|
// succeeds, as the first caller for an unbound name).
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
|
|||||||
func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
|
func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
|
||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("node-c", "rt-new", true, t0)})
|
||||||
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||||
if mm.state["laptop"].Licence != licence1 {
|
if mm.state["laptop"].Licence != licence1 {
|
||||||
@@ -477,16 +477,16 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "node-b.node-tools"})
|
||||||
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "node-b.node-tools" {
|
||||||
t.Fatalf("the node is told %+v", s)
|
t.Fatalf("the node is told %+v", s)
|
||||||
}
|
}
|
||||||
said := strings.Join(lines, "\n")
|
said := strings.Join(lines, "\n")
|
||||||
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
|
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
|
||||||
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
!strings.Contains(said, "the switch verb; asked by node-b.node-tools") {
|
||||||
t.Fatalf("the journal said:\n%s", said)
|
t.Fatalf("the journal said:\n%s", said)
|
||||||
}
|
}
|
||||||
var switched string
|
var switched string
|
||||||
@@ -495,18 +495,18 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
|||||||
switched = e
|
switched = e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
for _, want := range []string{`"by":"node-b.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
||||||
if !strings.Contains(switched, want) {
|
if !strings.Contains(switched, want) {
|
||||||
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
last := mm.store.Audits[len(mm.store.Audits)-1]
|
last := mm.store.Audits[len(mm.store.Audits)-1]
|
||||||
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"node-b.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
||||||
t.Fatalf("the audit kept %s", raw)
|
t.Fatalf("the audit kept %s", raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
_, _ = 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":"novox.node-tools"`) {
|
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"node-c.node-tools"`) {
|
||||||
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
|
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 |
|
| workstation | queue | device | prints through | driver package installed |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused |
|
| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused |
|
||||||
| desktop | `Brother_MFC_Novox` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused |
|
| desktop | `Brother_MFC_NodeC` (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) |
|
| 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,
|
**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":
|
case "lpstat -r":
|
||||||
return ok("scheduler is running\n")
|
return ok("scheduler is running\n")
|
||||||
case "lpstat -d":
|
case "lpstat -d":
|
||||||
return ok("system default destination: Brother_MFC_Novox\n")
|
return ok("system default destination: Brother_MFC_NodeC\n")
|
||||||
case "lpstat -p":
|
case "lpstat -p":
|
||||||
return ok("printer Brother_MFC_Novox is idle. enabled since Mon Jun 29 21:34:30 2026\n" +
|
return ok("printer Brother_MFC_NodeC 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")
|
"printer Kanjuro disabled since Sun Jun 9 11:16:03 2024 -\n\tPaused\n")
|
||||||
case "lpstat -a":
|
case "lpstat -a":
|
||||||
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")
|
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_Novox":
|
case "lpoptions -p Brother_MFC_NodeC":
|
||||||
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`)
|
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":
|
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`)
|
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) {
|
func TestPrintersReadsStateDeviceModelAndSupplies(t *testing.T) {
|
||||||
theDesktop(t, nil)
|
theDesktop(t, nil)
|
||||||
got, err := Printers()
|
got, err := Printers()
|
||||||
if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_Novox" || got.Scheduler != "scheduler is running" {
|
if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_NodeC" || got.Scheduler != "scheduler is running" {
|
||||||
t.Fatalf("%+v %v", got, err)
|
t.Fatalf("%+v %v", got, err)
|
||||||
}
|
}
|
||||||
b, k := got.Printers[0], got.Printers[1]
|
b, k := got.Printers[0], got.Printers[1]
|
||||||
@@ -186,11 +186,11 @@ func TestDefaultReadsAndSetsThroughSudo(t *testing.T) {
|
|||||||
return Result{}, false
|
return Result{}, false
|
||||||
})
|
})
|
||||||
got, err := Default("")
|
got, err := Default("")
|
||||||
if err != nil || got["default"] != "Brother_MFC_Novox" {
|
if err != nil || got["default"] != "Brother_MFC_NodeC" {
|
||||||
t.Fatalf("%v %v", got, err)
|
t.Fatalf("%v %v", got, err)
|
||||||
}
|
}
|
||||||
got, err = Default("Kanjuro")
|
got, err = Default("Kanjuro")
|
||||||
if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_Novox" {
|
if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_NodeC" {
|
||||||
t.Fatalf("%v %v", got, err)
|
t.Fatalf("%v %v", got, err)
|
||||||
}
|
}
|
||||||
if l := f.lines(); l[len(l)-1] != "sudo -n lpadmin -d Kanjuro" {
|
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, shown, said, store := newDesk(t, "laptop")
|
||||||
d.Paused("laptop", false)
|
d.Paused("laptop", false)
|
||||||
d.SayStanding()
|
d.SayStanding()
|
||||||
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
|
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on node-d",
|
||||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
||||||
take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank",
|
take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank",
|
||||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
|
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])
|
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.
|
// 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 shanks",
|
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on node-d",
|
||||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
||||||
last := (*shown)[len(*shown)-1]
|
last := (*shown)[len(*shown)-1]
|
||||||
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
|
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.
|
// was not shown; a message under a new handle replaces nothing.
|
||||||
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
||||||
d, shown, _, store := newDesk(t, "laptop", "desktop")
|
d, shown, _, store := newDesk(t, "laptop", "desktop")
|
||||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on node-d"})
|
||||||
take(t, d, asks.Send, asks.Message{Handle: "m2", Title: "another problem"})
|
take(t, d, asks.Send, asks.Message{Handle: "m2", Title: "another problem"})
|
||||||
if len(*shown) != 4 {
|
if len(*shown) != 4 {
|
||||||
t.Fatalf("shown %d", len(*shown))
|
t.Fatalf("shown %d", len(*shown))
|
||||||
@@ -342,7 +342,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
|||||||
first := store["m1"].On
|
first := store["m1"].On
|
||||||
// The desk now names a third machine as well.
|
// The desk now names a third machine as well.
|
||||||
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
|
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 shanks", Urgent: true,
|
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on node-d", Urgent: true,
|
||||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||||
again := (*shown)[4:]
|
again := (*shown)[4:]
|
||||||
if len(again) != 3 {
|
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.
|
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
|
||||||
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
||||||
d, shown, said, store := newDesk(t, "laptop", "desktop")
|
d, shown, said, store := newDesk(t, "laptop", "desktop")
|
||||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on node-d"})
|
||||||
first := store["m1"].On
|
first := store["m1"].On
|
||||||
notify := d.Notify
|
notify := d.Notify
|
||||||
d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) {
|
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)
|
return notify(machine, args)
|
||||||
}
|
}
|
||||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
|
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on node-d"})
|
||||||
if got := store["m1"].On["desktop"]; got != first["desktop"] {
|
if got := store["m1"].On["desktop"]; got != first["desktop"] {
|
||||||
t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On)
|
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") }
|
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 shanks"})
|
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on node-d"})
|
||||||
if len(said[asks.IntakeSubject(asks.FailedWhat, Kind)]) != 1 {
|
if len(said[asks.IntakeSubject(asks.FailedWhat, Kind)]) != 1 {
|
||||||
t.Fatalf("shown nowhere, and not said as failed")
|
t.Fatalf("shown nowhere, and not said as failed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ machine's resources a seat, these tools become its verbs.
|
|||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
All five only read.
|
All six only read.
|
||||||
|
|
||||||
| tool | what | replaces |
|
| tool | what | replaces |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -32,6 +32,7 @@ All five only read.
|
|||||||
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
|
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
|
||||||
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
||||||
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
||||||
|
| `disk_folders` | Which folders fill a disk, like `du -x`: the largest folders under `path` (default `/`) to `depth` levels (default 2, at most 3), `limit` of them (default 20, at most 100), with bytes and GiB. Sizes are allocated bytes, a hard link once. It stays on the path's one filesystem: a mount below it is named, never entered. It reads every folder the tool runner can read (docker's included) and no content; folders it cannot read are counted and named. After 25 s (the tool runner gives a call 30 s) it answers what it has measured, marked `partial`, with `stopped_at` (novox/hq issue 1804) | `du -x`, `ncdu` |
|
||||||
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
|
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
|
||||||
|
|
||||||
### How each number is read
|
### How each number is read
|
||||||
@@ -87,4 +88,5 @@ cover:
|
|||||||
- processes ranked, one that started inside the window left out, unreadable ones counted;
|
- processes ranked, one that started inside the window left out, unreadable ones counted;
|
||||||
- filesystems said once, mount escapes undone, a statfs that fails said;
|
- filesystems said once, mount escapes undone, a statfs that fails said;
|
||||||
- the window's bounds;
|
- the window's bounds;
|
||||||
|
- folders ranked biggest first to the depth asked, a mount never entered, unreadable folders named, a walk past its bound answered as partial with where it stopped, a hard link counted once;
|
||||||
- the manifest: tools listed equal tools served, all read-only, no resources, no machine named.
|
- the manifest: tools listed equal tools served, all read-only, no resources, no machine named.
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FolderBudget is how long a folder walk may take before it answers what it has measured.
|
||||||
|
const FolderBudget = 25 * time.Second
|
||||||
|
|
||||||
|
// namedUnreadable is how many unreadable folders are named; the rest are only counted.
|
||||||
|
const namedUnreadable = 50
|
||||||
|
|
||||||
|
// Folder is one folder's size: everything under it on the same filesystem.
|
||||||
|
type Folder struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Depth int `json:"depth"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
GiB float64 `json:"gib"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderReport is the answer of disk_folders.
|
||||||
|
type FolderReport struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
GiB float64 `json:"gib"`
|
||||||
|
Largest []Folder `json:"largest"`
|
||||||
|
Partial bool `json:"partial"`
|
||||||
|
StoppedAt string `json:"stopped_at,omitempty"`
|
||||||
|
Unreadable int `json:"unreadable"`
|
||||||
|
UnreadableFolders []string `json:"unreadable_folders,omitempty"`
|
||||||
|
Skipped []string `json:"other_filesystems_skipped,omitempty"`
|
||||||
|
Said []string `json:"said"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) readDir(real string) ([]os.DirEntry, error) {
|
||||||
|
if m.ReadDir != nil {
|
||||||
|
return m.ReadDir(real)
|
||||||
|
}
|
||||||
|
return os.ReadDir(real)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) now() time.Time {
|
||||||
|
if m.Clock != nil {
|
||||||
|
return m.Clock()
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func devOf(path string, info fs.FileInfo) uint64 {
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
return uint64(st.Dev)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type inodeKey struct{ dev, ino uint64 }
|
||||||
|
|
||||||
|
// walk is one folder measurement.
|
||||||
|
type walk struct {
|
||||||
|
m *Machine
|
||||||
|
maxDepth int
|
||||||
|
dev uint64
|
||||||
|
deadline time.Time
|
||||||
|
seen map[inodeKey]bool
|
||||||
|
folders []Folder
|
||||||
|
bad []string
|
||||||
|
skipped []string
|
||||||
|
stopped string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folders measures path like du -x: allocated bytes, a hard link once, never entering another filesystem.
|
||||||
|
func (m *Machine) Folders(path string, depth, limit int) (FolderReport, error) {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return FolderReport{}, fmt.Errorf("path must be absolute, not %q", path)
|
||||||
|
}
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
info, err := os.Stat(m.path(path))
|
||||||
|
if err != nil {
|
||||||
|
return FolderReport{}, fmt.Errorf("path %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return FolderReport{}, fmt.Errorf("path %s is not a folder", path)
|
||||||
|
}
|
||||||
|
budget := m.Budget
|
||||||
|
if budget == 0 {
|
||||||
|
budget = FolderBudget
|
||||||
|
}
|
||||||
|
w := &walk{m: m, maxDepth: depth, deadline: m.now().Add(budget), seen: map[inodeKey]bool{}}
|
||||||
|
w.dev = w.device(path, info)
|
||||||
|
total := w.dir(path, 0)
|
||||||
|
|
||||||
|
sort.SliceStable(w.folders, func(i, j int) bool {
|
||||||
|
if w.folders[i].Bytes != w.folders[j].Bytes {
|
||||||
|
return w.folders[i].Bytes > w.folders[j].Bytes
|
||||||
|
}
|
||||||
|
return w.folders[i].Path < w.folders[j].Path
|
||||||
|
})
|
||||||
|
if len(w.folders) > limit {
|
||||||
|
w.folders = w.folders[:limit]
|
||||||
|
}
|
||||||
|
sort.Strings(w.bad)
|
||||||
|
named := w.bad
|
||||||
|
if len(named) > namedUnreadable {
|
||||||
|
named = named[:namedUnreadable]
|
||||||
|
}
|
||||||
|
r := FolderReport{
|
||||||
|
Path: path, Bytes: total, GiB: gibOf(total), Largest: w.folders,
|
||||||
|
Partial: w.stopped != "", StoppedAt: w.stopped,
|
||||||
|
Unreadable: len(w.bad), UnreadableFolders: named, Skipped: w.skipped,
|
||||||
|
}
|
||||||
|
if r.Largest == nil {
|
||||||
|
r.Largest = []Folder{}
|
||||||
|
}
|
||||||
|
var said []string
|
||||||
|
if r.Partial {
|
||||||
|
said = append(said, fmt.Sprintf("PARTIAL: the walk stopped after %s at %s; sizes are what was measured so far", budget, r.StoppedAt))
|
||||||
|
}
|
||||||
|
said = append(said, fmt.Sprintf("%s holds %.1f GiB on its own filesystem", path, r.GiB))
|
||||||
|
if r.Unreadable > 0 {
|
||||||
|
said = append(said, fmt.Sprintf("%d folders could not be read (%s), so their size is missing", r.Unreadable, strings.Join(named, ", ")))
|
||||||
|
}
|
||||||
|
if len(r.Skipped) > 0 {
|
||||||
|
said = append(said, "other filesystems not entered: "+strings.Join(r.Skipped, ", "))
|
||||||
|
}
|
||||||
|
r.Said = said
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *walk) device(path string, info fs.FileInfo) uint64 {
|
||||||
|
if w.m.DevOf != nil {
|
||||||
|
return w.m.DevOf(path, info)
|
||||||
|
}
|
||||||
|
return devOf(path, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dir is the allocated bytes under a folder; it records the folder when it is within the depth.
|
||||||
|
func (w *walk) dir(path string, depth int) int64 {
|
||||||
|
if w.stopped != "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if w.m.now().After(w.deadline) {
|
||||||
|
w.stopped = path
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
entries, err := w.m.readDir(w.m.path(path))
|
||||||
|
if err != nil {
|
||||||
|
w.bad = append(w.bad, path)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, e := range entries {
|
||||||
|
child := filepath.Join(path, e.Name())
|
||||||
|
info, err := os.Lstat(w.m.path(child))
|
||||||
|
if err != nil {
|
||||||
|
continue // gone since it was listed
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
if w.device(child, info) != w.dev {
|
||||||
|
w.skipped = append(w.skipped, child)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += w.allocated(info, false) + w.dir(child, depth+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += w.allocated(info, true)
|
||||||
|
}
|
||||||
|
if depth >= 1 && depth <= w.maxDepth {
|
||||||
|
w.folders = append(w.folders, Folder{Path: path, Depth: depth, Bytes: total, GiB: gibOf(total)})
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocated is the bytes an entry occupies on disk, as du counts them; a file with several links once.
|
||||||
|
func (w *walk) allocated(info fs.FileInfo, file bool) int64 {
|
||||||
|
st, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return info.Size()
|
||||||
|
}
|
||||||
|
if file && st.Nlink > 1 {
|
||||||
|
k := inodeKey{uint64(st.Dev), uint64(st.Ino)}
|
||||||
|
if w.seen[k] {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
w.seen[k] = true
|
||||||
|
}
|
||||||
|
return st.Blocks * 512
|
||||||
|
}
|
||||||
|
|
||||||
|
func gibOf(b int64) float64 { return math.Round(float64(b)/gib*100) / 100 }
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mb = 1 << 20
|
||||||
|
|
||||||
|
// megabytes writes a file of n MiB under the fake root.
|
||||||
|
func (f *fake) megabytes(path string, n int) { f.file(path, strings.Repeat("x", n*mb)) }
|
||||||
|
|
||||||
|
func aFullDisk(t *testing.T) *fake {
|
||||||
|
f := newFake(t)
|
||||||
|
f.megabytes("var/lib/docker/overlay2/a/layer", 8)
|
||||||
|
f.megabytes("var/lib/docker/overlay2/b/layer", 4)
|
||||||
|
f.megabytes("var/log/big.log", 3)
|
||||||
|
f.megabytes("home/jo/video.mkv", 6)
|
||||||
|
f.megabytes("home/jo/deep/er/est/file", 1)
|
||||||
|
f.dir("empty")
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func folders(t *testing.T, m *Machine, args map[string]any) FolderReport {
|
||||||
|
t.Helper()
|
||||||
|
out, err := tool(t, m, "disk_folders").Run(args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out.(FolderReport)
|
||||||
|
}
|
||||||
|
|
||||||
|
func paths(r FolderReport) string {
|
||||||
|
var p []string
|
||||||
|
for _, f := range r.Largest {
|
||||||
|
p = append(p, f.Path)
|
||||||
|
}
|
||||||
|
return strings.Join(p, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1804/AC1: given a disk with folders of known sizes, when disk_folders runs with the defaults, then it answers
|
||||||
|
// the largest folders to depth 2, biggest first, with bytes and GiB, and the total of the path.
|
||||||
|
func TestFoldersAreRankedBiggestFirstToDepthTwoByDefault(t *testing.T) {
|
||||||
|
r := folders(t, aFullDisk(t).machine(), nil)
|
||||||
|
if got := paths(r); !strings.HasPrefix(got, "/var,/var/lib,") || !strings.Contains(got, "/home,/home/jo") {
|
||||||
|
t.Fatalf("ranked %s", got)
|
||||||
|
}
|
||||||
|
if r.Path != "/" || r.Partial || r.Unreadable != 0 {
|
||||||
|
t.Errorf("%+v", r)
|
||||||
|
}
|
||||||
|
top := r.Largest[0]
|
||||||
|
if top.Path != "/var" || top.Depth != 1 || top.Bytes < 15*mb || top.Bytes > 16*mb || top.GiB <= 0 {
|
||||||
|
t.Errorf("top %+v", top)
|
||||||
|
}
|
||||||
|
for _, f := range r.Largest {
|
||||||
|
if f.Depth > 2 || f.Depth < 1 {
|
||||||
|
t.Errorf("%+v is outside depth 2", f)
|
||||||
|
}
|
||||||
|
if strings.Contains(f.Path, "overlay2") {
|
||||||
|
t.Errorf("%s is deeper than 2", f.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Bytes < 22*mb || r.Bytes > 24*mb {
|
||||||
|
t.Errorf("total %d", r.Bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1804/AC2: given a path, a depth and a limit, then only that folder is measured, depth is held at 3 and limit at 100.
|
||||||
|
func TestFoldersTakeAPathADepthAndALimitHeldAtThreeAndAHundred(t *testing.T) {
|
||||||
|
f := aFullDisk(t)
|
||||||
|
r := folders(t, f.machine(), map[string]any{"path": "/var/lib/docker", "depth": 1.0})
|
||||||
|
if r.Path != "/var/lib/docker" || paths(r) != "/var/lib/docker/overlay2" {
|
||||||
|
t.Errorf("%s: %s", r.Path, paths(r))
|
||||||
|
}
|
||||||
|
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0, "limit": 2.0})
|
||||||
|
if len(r.Largest) != 2 {
|
||||||
|
t.Errorf("limit 2 gave %d", len(r.Largest))
|
||||||
|
}
|
||||||
|
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0})
|
||||||
|
if got := paths(r); !strings.Contains(got, "/home/jo/deep/er") || strings.Contains(got, "/home/jo/deep/er/est") {
|
||||||
|
t.Errorf("depth 3 under /home is /home/jo/deep/er: %s", got)
|
||||||
|
}
|
||||||
|
tl := tool(t, f.machine(), "disk_folders")
|
||||||
|
for _, bad := range []map[string]any{{"depth": 0.0}, {"limit": 0.0}, {"depth": "deep"}, {"path": "relative/dir"}, {"path": "/nope"}, {"path": "/var/log/big.log"}} {
|
||||||
|
if _, err := tl.Run(bad); err == nil {
|
||||||
|
t.Errorf("%v was accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 120; i++ {
|
||||||
|
f.file(fmt.Sprintf("many/d%03d/f", i), "x")
|
||||||
|
}
|
||||||
|
if r := folders(t, f.machine(), map[string]any{"path": "/many", "limit": 500.0}); len(r.Largest) != 100 {
|
||||||
|
t.Errorf("limit held at %d, not 100", len(r.Largest))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1804/AC3: given a mount below the path, then it is never entered or counted, and is named.
|
||||||
|
func TestFoldersNeverCrossIntoAnotherFilesystem(t *testing.T) {
|
||||||
|
f := aFullDisk(t)
|
||||||
|
m := f.machine()
|
||||||
|
m.DevOf = func(path string, info fs.FileInfo) uint64 {
|
||||||
|
if path == "/var/lib/docker" {
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
r := folders(t, m, nil)
|
||||||
|
if strings.Contains(paths(r), "docker") || len(r.Skipped) != 1 || r.Skipped[0] != "/var/lib/docker" {
|
||||||
|
t.Errorf("%s, skipped %v", paths(r), r.Skipped)
|
||||||
|
}
|
||||||
|
if r.Bytes > 14*mb {
|
||||||
|
t.Errorf("total %d includes the mount", r.Bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1804/AC4: given folders it cannot read, then they are counted and named and the rest is still answered.
|
||||||
|
func TestUnreadableFoldersAreCountedAndNamed(t *testing.T) {
|
||||||
|
f := aFullDisk(t)
|
||||||
|
m := f.machine()
|
||||||
|
real := os.ReadDir
|
||||||
|
m.ReadDir = func(p string) ([]os.DirEntry, error) {
|
||||||
|
if strings.HasSuffix(p, "/var/log") || strings.HasSuffix(p, "/overlay2/b") {
|
||||||
|
return nil, fmt.Errorf("open %s: permission denied", p)
|
||||||
|
}
|
||||||
|
return real(p)
|
||||||
|
}
|
||||||
|
r := folders(t, m, map[string]any{"depth": 3.0})
|
||||||
|
if r.Unreadable != 2 || strings.Join(r.UnreadableFolders, ",") != "/var/lib/docker/overlay2/b,/var/log" {
|
||||||
|
t.Errorf("%d: %v", r.Unreadable, r.UnreadableFolders)
|
||||||
|
}
|
||||||
|
if r.Partial || !strings.Contains(strings.Join(r.Said, "\n"), "2 folders could not be read") {
|
||||||
|
t.Errorf("%+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1804/AC5: given a walk that outlasts its bound, then it answers what it measured, partial, with where it stopped.
|
||||||
|
func TestALongWalkAnswersPartialAndSaysWhereItStopped(t *testing.T) {
|
||||||
|
f := aFullDisk(t)
|
||||||
|
m := f.machine()
|
||||||
|
now := time.Unix(0, 0)
|
||||||
|
m.Clock = func() time.Time { now = now.Add(10 * time.Second); return now }
|
||||||
|
r := folders(t, m, nil)
|
||||||
|
if !r.Partial || r.StoppedAt == "" || len(r.Said) < 2 || !strings.HasPrefix(r.Said[0], "PARTIAL") || !strings.Contains(r.Said[0], r.StoppedAt) {
|
||||||
|
t.Errorf("%+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1804/AC6: a hard link is counted once, and the tool changes nothing.
|
||||||
|
func TestAHardLinkIsCountedOnce(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
f.megabytes("a/big", 5)
|
||||||
|
if err := os.Link(f.root+"/a/big", f.root+"/a/again"); err != nil {
|
||||||
|
t.Skip(err)
|
||||||
|
}
|
||||||
|
r := folders(t, f.machine(), nil)
|
||||||
|
if r.Bytes > 6*mb {
|
||||||
|
t.Errorf("counted twice: %d", r.Bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1804/AC7: the bound sits below the 30 s the tool runner gives a call, so a partial answer reaches its caller.
|
||||||
|
func TestTheWalkBoundIsBelowTheToolRunnersCallLimit(t *testing.T) {
|
||||||
|
if FolderBudget >= 30*time.Second {
|
||||||
|
t.Errorf("the budget %s is not below the 30 s a call gets", FolderBudget)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -53,6 +54,13 @@ type Machine struct {
|
|||||||
Run Runner
|
Run Runner
|
||||||
Wait func(time.Duration)
|
Wait func(time.Duration)
|
||||||
Statfs func(path string) (FSStat, error)
|
Statfs func(path string) (FSStat, error)
|
||||||
|
|
||||||
|
// The folder walk's seams, all optional: the folder reader, the filesystem a path is on, the clock and
|
||||||
|
// the time the walk may take (60 s when zero).
|
||||||
|
ReadDir func(path string) ([]os.DirEntry, error)
|
||||||
|
DevOf func(path string, info fs.FileInfo) uint64
|
||||||
|
Clock func() time.Time
|
||||||
|
Budget time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Here is the machine this process runs on.
|
// Here is the machine this process runs on.
|
||||||
|
|||||||
@@ -136,6 +136,34 @@ func Tools(m *Machine) []stdio.Tool {
|
|||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "disk_folders",
|
||||||
|
Description: "Which folders fill a disk, like du -x: the largest folders under a path, measured on disk (allocated bytes, a hard link " +
|
||||||
|
"once), staying on the path's one filesystem (a mount below it is named, never entered). path defaults to /, depth to 2 (at most 3), " +
|
||||||
|
"limit to 20 (at most 100). Sizes in bytes and GiB. It reads every folder the tool runner can read, none of the content; folders " +
|
||||||
|
"it cannot read are counted and named. It stops after 25 s and answers what it measured so far, marked partial, with where it stopped. " +
|
||||||
|
"Replaces du. (r)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"path": map[string]any{"type": "string", "description": "the absolute folder to measure (default /)"},
|
||||||
|
"depth": map[string]any{"type": "integer", "description": "how many levels below path to rank (default 2, at most 3)"},
|
||||||
|
"limit": map[string]any{"type": "integer", "description": "how many folders to answer (default 20, at most 100)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
depth, err := count(args, "depth", 2, 1, 3)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
limit, err := count(args, "limit", 20, 1, 100)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := text(args, "path")
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
return m.Folders(path, depth, limit)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "disk_filesystems",
|
Name: "disk_filesystems",
|
||||||
Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " +
|
Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " +
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ func TestTheManifestNamesExactlyTheToolsTheBundleServesAndNoMachine(t *testing.T
|
|||||||
if len(m.Resources) != 0 {
|
if len(m.Resources) != 0 {
|
||||||
t.Errorf("the module only reads, and declares %d resources", len(m.Resources))
|
t.Errorf("the module only reads, and declares %d resources", len(m.Resources))
|
||||||
}
|
}
|
||||||
for _, banned := range []string{"g14", "novox", "ace", "shanks", "jochen", "/home/"} {
|
// 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/"} {
|
||||||
if strings.Contains(string(raw), banned) {
|
if strings.Contains(string(raw), banned) {
|
||||||
t.Errorf("the manifest says %q", banned)
|
t.Errorf("the manifest says %q", banned)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"disk_load",
|
"disk_load",
|
||||||
"disk_top",
|
"disk_top",
|
||||||
"disk_filesystems",
|
"disk_filesystems",
|
||||||
|
"disk_folders",
|
||||||
"disk_devices",
|
"disk_devices",
|
||||||
"cpu_top"
|
"cpu_top"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(ConfigFile, "bind-interfaces\nlisten-address=127.0.0.2\nlisten-address=127.0.0.1\nno-resolv\n")
|
||||||
write(NodesFile, "local=/internal/\naddress=/novox.internal/127.0.0.2\nhost-record=novox.internal,127.0.0.2\n"+
|
write(NodesFile, "local=/internal/\naddress=/node-c.internal/127.0.0.2\nhost-record=node-c.internal,127.0.0.2\n"+
|
||||||
"address=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n")
|
"address=/node-a.internal/10.77.0.2\nhost-record=node-a.internal,10.77.0.2\n")
|
||||||
if err := os.MkdirAll(filepath.Join(root, filepath.Dir(StateFile)), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, filepath.Dir(StateFile)), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -147,13 +147,13 @@ func TestTheConfigurationAndTheRosterSayWhereAndWhatToAsk(t *testing.T) {
|
|||||||
if strings.Join(listen, " ") != "10.77.0.1 127.0.0.1" {
|
if strings.Join(listen, " ") != "10.77.0.1 127.0.0.1" {
|
||||||
t.Fatalf("listen addresses %v", listen)
|
t.Fatalf("listen addresses %v", listen)
|
||||||
}
|
}
|
||||||
names := AnsweredNames("local=/internal/\naddress=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n" +
|
names := AnsweredNames("local=/internal/\naddress=/node-a.internal/10.77.0.2\nhost-record=node-a.internal,10.77.0.2\n" +
|
||||||
"host-record=novox.internal,10.77.0.1\n")
|
"host-record=node-c.internal,10.77.0.1\n")
|
||||||
q, ok := Question(listen, names)
|
q, ok := Question(listen, names)
|
||||||
if !ok || q.Name != "novox.internal" || q.Address != "10.77.0.1" {
|
if !ok || q.Name != "node-c.internal" || q.Address != "10.77.0.1" {
|
||||||
t.Fatalf("asks %+v", q)
|
t.Fatalf("asks %+v", q)
|
||||||
}
|
}
|
||||||
if q, _ := Question([]string{"127.0.0.1"}, names); q.Name != "ace.internal" {
|
if q, _ := Question([]string{"127.0.0.1"}, names); q.Name != "node-a.internal" {
|
||||||
t.Fatalf("with no own name in the roster, asks %+v", q)
|
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)
|
m := aResolverMachine(t, port, time.Now)
|
||||||
private := startStandIn(t, "127.0.0.2", port, "127.0.0.2")
|
private := startStandIn(t, "127.0.0.2", port, "127.0.0.2")
|
||||||
startStandIn(t, "127.0.0.1", 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 != "novox.internal" {
|
if l := m.Look(t.Context()); !l.Healthy || len(l.Answers) != 4 || l.Asked.Name != "node-c.internal" {
|
||||||
t.Fatalf("a resolver answering everywhere: %+v", l)
|
t.Fatalf("a resolver answering everywhere: %+v", l)
|
||||||
}
|
}
|
||||||
private.stop()
|
private.stop()
|
||||||
@@ -173,14 +173,14 @@ func TestALookFindsAResolverThatStoppedAnsweringOnOneAddress(t *testing.T) {
|
|||||||
if l.Healthy || len(l.Wrong) != 2 {
|
if l.Healthy || len(l.Wrong) != 2 {
|
||||||
t.Fatalf("a resolver that stopped answering on its private address: %+v", l)
|
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 novox.internal: refused") ||
|
if why := l.Why(); !strings.Contains(why, "127.0.0.2:"+port+" over udp did not answer node-c.internal: refused") ||
|
||||||
!strings.Contains(why, "over tcp") || strings.Contains(why, "127.0.0.1:") {
|
!strings.Contains(why, "over tcp") || strings.Contains(why, "127.0.0.1:") {
|
||||||
t.Fatalf("said as %q", why)
|
t.Fatalf("said as %q", why)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAWrongAnswerIsNotAnAnswer(t *testing.T) {
|
func TestAWrongAnswerIsNotAnAnswer(t *testing.T) {
|
||||||
q := Named{Name: "novox.internal", Address: "10.77.0.1"}
|
q := Named{Name: "node-c.internal", Address: "10.77.0.1"}
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
a Answer
|
a Answer
|
||||||
want string
|
want string
|
||||||
@@ -198,7 +198,7 @@ func TestAWrongAnswerIsNotAnAnswer(t *testing.T) {
|
|||||||
|
|
||||||
func failing(at time.Time) Look {
|
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"}},
|
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 novox.internal: refused: nothing listens there"}}
|
Wrong: []string{"10.77.0.1:53 over udp did not answer node-c.internal: refused: nothing listens there"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func healthy(at time.Time) Look { return Look{At: at, Healthy: true} }
|
func healthy(at time.Time) Look { return Look{At: at, Healthy: true} }
|
||||||
@@ -323,7 +323,7 @@ func TestTheWatchRestartsAResolverThatStoppedAnsweringAndHealthSaysWhetherItHelp
|
|||||||
look()
|
look()
|
||||||
}
|
}
|
||||||
h := m.Health(t.Context())
|
h := m.Health(t.Context())
|
||||||
if h.Healthy || !strings.Contains(h.Why, "did not answer novox.internal: refused") ||
|
if h.Healthy || !strings.Contains(h.Why, "did not answer node-c.internal: refused") ||
|
||||||
!strings.Contains(h.Why, "which did not help") {
|
!strings.Contains(h.Why, "which did not help") {
|
||||||
t.Fatalf("a restart that did not help: %q", h.Why)
|
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)
|
m := aResolverMachine(t, port, time.Now)
|
||||||
startStandIn(t, "127.0.0.2", port, "127.0.0.2")
|
startStandIn(t, "127.0.0.2", port, "127.0.0.2")
|
||||||
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
|
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
|
||||||
if _, err := m.Resolve(t.Context(), "novox.internal", "8.8.8.8", ""); err == nil || !strings.Contains(err.Error(), "not one the resolver listens on") {
|
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") {
|
||||||
t.Fatalf("another address was asked: %v", err)
|
t.Fatalf("another address was asked: %v", err)
|
||||||
}
|
}
|
||||||
got, err := m.Resolve(t.Context(), "novox.internal", "127.0.0.2", "tcp")
|
got, err := m.Resolve(t.Context(), "node-c.internal", "127.0.0.2", "tcp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
if len(answers) != 1 || answers[0].Over != "tcp" || answers[0].Code != "NOERROR" || answers[0].Addresses[0] != "127.0.0.2" {
|
||||||
t.Fatalf("%+v", answers)
|
t.Fatalf("%+v", answers)
|
||||||
}
|
}
|
||||||
if got, _ := m.Resolve(t.Context(), "novox.internal", "", ""); len(got.(map[string]any)["answers"].([]Answer)) != 4 {
|
if got, _ := m.Resolve(t.Context(), "node-c.internal", "", ""); len(got.(map[string]any)["answers"].([]Answer)) != 4 {
|
||||||
t.Fatalf("every listen address over both: %+v", got)
|
t.Fatalf("every listen address over both: %+v", got)
|
||||||
}
|
}
|
||||||
if _, err := m.Resolve(t.Context(), "novox.internal", "", "quic"); err == nil {
|
if _, err := m.Resolve(t.Context(), "node-c.internal", "", "quic"); err == nil {
|
||||||
t.Fatal("over quic was accepted")
|
t.Fatal("over quic was accepted")
|
||||||
}
|
}
|
||||||
if _, err := m.Resolve(t.Context(), "", "", ""); err == nil {
|
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.
|
// The roster changes: one machine added, one gone. An unreadable roster says nothing.
|
||||||
nodes := filepath.Join(m.Root, NodesFile)
|
nodes := filepath.Join(m.Root, NodesFile)
|
||||||
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 {
|
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 {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
Tell(m, &a, emit)
|
Tell(m, &a, emit)
|
||||||
if got := strings.Join(said, "|"); !strings.Contains(got, "name.added shanks.internal") ||
|
if got := strings.Join(said, "|"); !strings.Contains(got, "name.added node-d.internal") ||
|
||||||
!strings.Contains(got, "name.removed ace.internal") || len(said) != 2 {
|
!strings.Contains(got, "name.removed node-a.internal") || len(said) != 2 {
|
||||||
t.Fatalf("the roster's change: %v", said)
|
t.Fatalf("the roster's change: %v", said)
|
||||||
}
|
}
|
||||||
said = nil
|
said = nil
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ A failure is an error naming how it failed, never an empty answer.
|
|||||||
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
|
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
|
||||||
| `docker_networks` | r | networks, subnets, and the containers on each |
|
| `docker_networks` | r | networks, subnets, and the containers on each |
|
||||||
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
|
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
|
||||||
|
| `docker_volumes_remove_anonymous` | a | leftover anonymous volumes no container mounts in any state (so none the mesh holds), optionally created before a time; each other volume answered with why it stays; none can be named. **A dry run unless `dry_run` is false, which needs `why`. Never forced** (issue 1796). Sizes only when `sizes` is true (docker is slow on very many volumes); at most `limit` (default 200) per call |
|
||||||
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise unless asked; **an exec's command line is shown with any secret it carried as `[redacted: <what it was>]`** |
|
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise unless asked; **an exec's command line is shown with any secret it carried as `[redacted: <what it was>]`** |
|
||||||
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
|
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
|
||||||
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
|
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
|
||||||
|
|||||||
@@ -944,16 +944,9 @@ func (c *Client) Volumes(ctx context.Context, unmountedOnly, sizes bool) (map[st
|
|||||||
}
|
}
|
||||||
size := map[string]string{}
|
size := map[string]string{}
|
||||||
if sizes {
|
if sizes {
|
||||||
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
|
if size, err = c.volumeSizes(ctx); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var v struct{ Volumes []map[string]any }
|
|
||||||
if err := json.Unmarshal([]byte(out), &v); err == nil {
|
|
||||||
for _, vol := range v.Volumes {
|
|
||||||
size[fmt.Sprint(vol["Name"])] = fmt.Sprint(vol["Size"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
vols := []map[string]any{}
|
vols := []map[string]any{}
|
||||||
if len(lines(names)) > 0 {
|
if len(lines(names)) > 0 {
|
||||||
@@ -1215,3 +1208,134 @@ func (c *Client) DaemonConfig(ctx context.Context) (map[string]any, error) {
|
|||||||
answer["note"] = "keys read only at start take effect at the daemon's next restart; with live-restore on, a restart keeps every container running"
|
answer["note"] = "keys read only at start take effect at the daemon's next restart; with live-restore on, a restart keeps every container running"
|
||||||
return answer, nil
|
return answer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// volumeSizes is every volume's size by name, from docker system df -v, which walks every volume:
|
||||||
|
// on a machine with a thousand of them it does not answer within a call's bound.
|
||||||
|
func (c *Client) volumeSizes(ctx context.Context) (map[string]string, error) {
|
||||||
|
size := map[string]string{}
|
||||||
|
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var v struct{ Volumes []map[string]any }
|
||||||
|
if err := json.Unmarshal([]byte(out), &v); err == nil {
|
||||||
|
for _, vol := range v.Volumes {
|
||||||
|
size[fmt.Sprint(vol["Name"])] = fmt.Sprint(vol["Size"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveVolumesAsk is what docker_volumes_remove_anonymous is asked to take.
|
||||||
|
type RemoveVolumesAsk struct {
|
||||||
|
DryRun bool
|
||||||
|
Why string
|
||||||
|
CreatedBefore *time.Time
|
||||||
|
Sizes bool
|
||||||
|
Limit int // how many to remove in one call; 0 is 200
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAnonymousVolumes removes the leftover anonymous volumes (novox/hq issue 1796): those docker
|
||||||
|
// labels anonymous, which no container mounts in any state, so none a mesh-held container holds. The
|
||||||
|
// classification is Volumes' own, not a copy. Every other volume is answered with why it stays. Each
|
||||||
|
// is removed by its own `docker volume rm`, never forced and never `volume prune`, so one the daemon
|
||||||
|
// refuses is reported and the rest still go.
|
||||||
|
func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk) (map[string]any, error) {
|
||||||
|
if !a.DryRun && strings.TrimSpace(a.Why) == "" {
|
||||||
|
return nil, fmt.Errorf("why is required when dry_run is false")
|
||||||
|
}
|
||||||
|
all, err := c.Volumes(ctx, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
candidates := []string{}
|
||||||
|
skipped := []map[string]any{}
|
||||||
|
skip := func(name, why string) { skipped = append(skipped, map[string]any{"name": name, "why": why}) }
|
||||||
|
for _, v := range all["volumes"].([]map[string]any) {
|
||||||
|
name := fmt.Sprint(v["name"])
|
||||||
|
switch by := v["mounted_by"].([]map[string]any); {
|
||||||
|
case v["anonymous"] != true:
|
||||||
|
skip(name, "not anonymous: a named volume is data")
|
||||||
|
case v["mesh_held"] == true:
|
||||||
|
skip(name, "mounted by a container the mesh holds")
|
||||||
|
case len(by) > 0:
|
||||||
|
skip(name, fmt.Sprintf("mounted by %v (%v)", by[0]["container"], by[0]["state"]))
|
||||||
|
case a.CreatedBefore != nil && !volumeOlder(fmt.Sprint(v["created"]), *a.CreatedBefore):
|
||||||
|
skip(name, "not created before "+a.CreatedBefore.Format(time.RFC3339)+" (created "+fmt.Sprint(v["created"])+")")
|
||||||
|
default:
|
||||||
|
candidates = append(candidates, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
answer := map[string]any{"dry_run": a.DryRun, "candidates": len(candidates)}
|
||||||
|
// Sizes are asked for, never needed: the listing walks every volume and may not answer, and
|
||||||
|
// then the call answers without them. Nothing below depends on it.
|
||||||
|
sizes := map[string]string{}
|
||||||
|
if a.Sizes {
|
||||||
|
if got, err := c.volumeSizes(ctx); err != nil {
|
||||||
|
answer["sizes_unknown"] = err.Error()
|
||||||
|
} else {
|
||||||
|
sizes = got
|
||||||
|
answer["sizes"] = sizes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum := func(names []string) int64 {
|
||||||
|
var t int64
|
||||||
|
for _, n := range names {
|
||||||
|
t += max(Bytes(sizes[n]), 0)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if a.DryRun {
|
||||||
|
answer["skipped"] = skipped
|
||||||
|
answer["would_remove"] = candidates
|
||||||
|
if len(sizes) > 0 {
|
||||||
|
answer["would_free_bytes"] = sum(candidates)
|
||||||
|
}
|
||||||
|
answer["note"] = "nothing was removed: call again with dry_run false and why to remove them (limit volumes at most per call)"
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
limit := a.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
take := candidates
|
||||||
|
if len(take) > limit {
|
||||||
|
take = candidates[:limit]
|
||||||
|
}
|
||||||
|
removed := []string{}
|
||||||
|
// Chunks of a hundred in one `docker volume rm`, so a thousand volumes are ten calls; a chunk
|
||||||
|
// docker refuses any of is done again one by one, so what it refuses is named.
|
||||||
|
for i := 0; i < len(take); i += 100 {
|
||||||
|
chunk := take[i:min(i+100, len(take))]
|
||||||
|
if _, err := c.docker(ctx, append([]string{"volume", "rm"}, chunk...)...); err == nil {
|
||||||
|
removed = append(removed, chunk...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, name := range chunk {
|
||||||
|
if _, err := c.docker(ctx, "volume", "rm", name); err != nil {
|
||||||
|
// The batch took it before it refused another (or before its bound ran out):
|
||||||
|
// a volume that is no longer there is a removed one.
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "no such volume") {
|
||||||
|
removed = append(removed, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skip(name, "docker refused: "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
removed = append(removed, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
answer["skipped"] = skipped
|
||||||
|
answer["removed"], answer["count"], answer["why"] = removed, len(removed), a.Why
|
||||||
|
answer["remaining"] = len(candidates) - len(take)
|
||||||
|
if len(sizes) > 0 {
|
||||||
|
answer["freed_bytes"] = sum(removed)
|
||||||
|
}
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// volumeOlder is whether a volume's CreatedAt is before the cutoff; one that cannot be read is not.
|
||||||
|
func volumeOlder(created string, cutoff time.Time) bool {
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, created)
|
||||||
|
return err == nil && t.Before(cutoff)
|
||||||
|
}
|
||||||
|
|||||||
@@ -431,3 +431,145 @@ func TestEventsCoveringTheWholeWindowSayItIsCompleteAndFilterTheTypeHere(t *test
|
|||||||
t.Fatalf("a full buffer reaching past the window: %v", got)
|
t.Fatalf("a full buffer reaching past the window: %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1796/AC1: only anonymous volumes nothing mounts go, each answered; a named, a mounted and a too young one stay with why.
|
||||||
|
func TestRemoveAnonymousVolumesTakesOnlyTheUnmountedAnonymousOnes(t *testing.T) {
|
||||||
|
f := machine().
|
||||||
|
on("docker volume ls --quiet", Ran{Stdout: "webdata\nanonmounted\nold\nyoung\nnamed\n"}).
|
||||||
|
on("docker volume inspect", Ran{Stdout: `[
|
||||||
|
{"Name":"webdata","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
|
||||||
|
{"Name":"old","Driver":"local","CreatedAt":"2026-09-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
|
||||||
|
{"Name":"young","Driver":"local","CreatedAt":"2026-10-04T11:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
|
||||||
|
{"Name":"named","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z"}]`}).
|
||||||
|
on("docker system df --verbose", Ran{Stdout: `{"Volumes":[{"Name":"old","Size":"1.5MB"},{"Name":"young","Size":"1kB"}]}`}).
|
||||||
|
on("docker volume rm old", Ran{Stdout: "old\n"})
|
||||||
|
c := client(f, 1000)
|
||||||
|
before := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
got, err := c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true, Sizes: true, CreatedBefore: &before})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.ran("docker volume rm") || !reflect.DeepEqual(got["would_remove"], []string{"old"}) {
|
||||||
|
t.Fatalf("dry run: %v %+v", got, f.calls)
|
||||||
|
}
|
||||||
|
if got["would_free_bytes"] != int64(1500000) || got["sizes"].(map[string]string)["old"] != "1.5MB" {
|
||||||
|
t.Fatalf("dry run size: %v", got)
|
||||||
|
}
|
||||||
|
skipped := got["skipped"].([]map[string]any)
|
||||||
|
why := map[string]string{}
|
||||||
|
for _, s := range skipped {
|
||||||
|
why[s["name"].(string)] = s["why"].(string)
|
||||||
|
}
|
||||||
|
if len(why) != 3 || !strings.Contains(why["webdata"], "mesh holds") || why["young"] == "" || why["named"] == "" {
|
||||||
|
t.Fatalf("skipped: %v", skipped)
|
||||||
|
}
|
||||||
|
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "leftovers", Sizes: true, CreatedBefore: &before})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !f.ran("docker volume rm old") || got["count"] != 1 || !reflect.DeepEqual(got["removed"], []string{"old"}) {
|
||||||
|
t.Fatalf("removed: %v %+v", got, f.calls)
|
||||||
|
}
|
||||||
|
if got["freed_bytes"] != int64(1500000) {
|
||||||
|
t.Fatalf("freed: %v", got)
|
||||||
|
}
|
||||||
|
for _, c := range f.calls {
|
||||||
|
if len(c.args) > 1 && c.args[0] == "volume" && c.args[1] == "rm" && (len(c.args) != 3 || c.args[2] != "old") {
|
||||||
|
t.Errorf("reached too far: %v", c.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1796/AC2: a volume that is mounted by a mesh-held container is never removed; the tool takes no name.
|
||||||
|
func TestRemoveAnonymousVolumesRefusesANamedVolumeAndKeepsWhatTheMeshHolds(t *testing.T) {
|
||||||
|
for _, tool := range tools(client(&fake{}, 1000)) {
|
||||||
|
if tool.Name != "docker_volumes_remove_anonymous" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, k := range []string{"name", "names", "volume", "volumes"} {
|
||||||
|
if _, err := tool.Run(map[string]any{k: "x", "dry_run": false, "why": "w"}); err == nil {
|
||||||
|
t.Errorf("%s accepted", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tool.Run(map[string]any{"dry_run": false}); err == nil {
|
||||||
|
t.Error("a removal without why was accepted")
|
||||||
|
}
|
||||||
|
if _, err := tool.Run(map[string]any{"created_before": "yesterday"}); err == nil {
|
||||||
|
t.Error("a bad time was accepted")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("tool not served")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1796/AC3: on a machine with a very large set of volumes docker's size listing does not answer in
|
||||||
|
// time; the tool still answers, and removal never depends on it.
|
||||||
|
func TestRemoveAnonymousVolumesAnswersWhenTheSizeListingDoesNot(t *testing.T) {
|
||||||
|
f := (&fake{}).
|
||||||
|
on("docker ps --all --quiet --no-trunc", Ran{}).
|
||||||
|
on("docker volume ls --quiet", Ran{Stdout: "a1\na2\na3\n"}).
|
||||||
|
on("docker volume inspect", Ran{Stdout: `[{"Name":"a1","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a2","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a3","Labels":{"com.docker.volume.anonymous":""}}]`}).
|
||||||
|
on("docker system df", Ran{Status: 124, Err: "no answer within 20 s"}).
|
||||||
|
on("docker volume rm", Ran{Stdout: "ok\n"})
|
||||||
|
c := client(f, 1000)
|
||||||
|
got, err := c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true})
|
||||||
|
if err != nil || f.ran("docker system df") || !reflect.DeepEqual(got["would_remove"], []string{"a1", "a2", "a3"}) {
|
||||||
|
t.Fatalf("default dry run: %v %v %+v", got, err, f.calls)
|
||||||
|
}
|
||||||
|
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true, Sizes: true})
|
||||||
|
if err != nil || !reflect.DeepEqual(got["would_remove"], []string{"a1", "a2", "a3"}) || got["sizes_unknown"] == nil {
|
||||||
|
t.Fatalf("sizes asked, df silent: %v %v", got, err)
|
||||||
|
}
|
||||||
|
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w", Sizes: true, Limit: 2})
|
||||||
|
if err != nil || got["count"] != 2 || got["remaining"] != 1 || !f.ran("docker volume rm a1 a2") {
|
||||||
|
t.Fatalf("removal: %v %v %+v", got, err, f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1796/AC4: a batch docker half-did is settled by name: what is already gone counts as removed, what is in use stays with why.
|
||||||
|
func TestRemoveAnonymousVolumesCountsWhatAFailedBatchAlreadyRemoved(t *testing.T) {
|
||||||
|
f := (&fake{}).
|
||||||
|
on("docker ps --all --quiet --no-trunc", Ran{}).
|
||||||
|
on("docker volume ls --quiet", Ran{Stdout: "a1\na2\na3\n"}).
|
||||||
|
on("docker volume inspect", Ran{Stdout: `[{"Name":"a1","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a2","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a3","Labels":{"com.docker.volume.anonymous":""}}]`}).
|
||||||
|
on("docker volume rm a1 a2 a3", Ran{Status: 1, Stdout: "a1\n", Stderr: "Error response from daemon: remove a2: volume is in use - [c1]\n"}).
|
||||||
|
on("docker volume rm a1", Ran{Status: 1, Stderr: "Error response from daemon: get a1: no such volume\n"}).
|
||||||
|
on("docker volume rm a2", Ran{Status: 1, Stderr: "Error response from daemon: remove a2: volume is in use - [c1]\n"}).
|
||||||
|
on("docker volume rm a3", Ran{Stdout: "a3\n"})
|
||||||
|
got, err := client(f, 1000).RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sk := got["skipped"].([]map[string]any)
|
||||||
|
if !reflect.DeepEqual(got["removed"], []string{"a1", "a3"}) || got["count"] != 2 || len(sk) != 1 || sk[0]["name"] != "a2" || !strings.Contains(sk[0]["why"].(string), "in use") {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1796/AC5: removal goes in batches of a hundred names.
|
||||||
|
func TestRemoveAnonymousVolumesRemovesInBatchesOfAHundred(t *testing.T) {
|
||||||
|
names, entries := []string{}, []string{}
|
||||||
|
for i := 0; i < 250; i++ {
|
||||||
|
n := fmt.Sprintf("v%03d", i)
|
||||||
|
names = append(names, n)
|
||||||
|
entries = append(entries, `{"Name":"`+n+`","Labels":{"com.docker.volume.anonymous":""}}`)
|
||||||
|
}
|
||||||
|
f := (&fake{}).
|
||||||
|
on("docker ps --all --quiet --no-trunc", Ran{}).
|
||||||
|
on("docker volume ls --quiet", Ran{Stdout: strings.Join(names, "\n")}).
|
||||||
|
on("docker volume inspect", Ran{Stdout: "[" + strings.Join(entries, ",") + "]"}).
|
||||||
|
on("docker volume rm", Ran{Stdout: "ok\n"})
|
||||||
|
got, err := client(f, 1000).RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w", Limit: 250})
|
||||||
|
if err != nil || got["count"] != 250 {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
sizes := []int{}
|
||||||
|
for _, c := range f.calls {
|
||||||
|
if len(c.args) > 1 && c.args[0] == "volume" && c.args[1] == "rm" {
|
||||||
|
sizes = append(sizes, len(c.args)-2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(sizes, []int{100, 100, 50}) {
|
||||||
|
t.Fatalf("batches %v", sizes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
)
|
)
|
||||||
@@ -286,6 +287,40 @@ func tools(c *Client) []stdio.Tool {
|
|||||||
return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false))
|
return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_volumes_remove_anonymous",
|
||||||
|
Description: "Remove the leftover anonymous volumes (novox/hq issue 1796): only a volume docker labels anonymous (as docker_volumes says), which no container mounts in any state " +
|
||||||
|
"and so none the mesh holds, and with created_before only one created before it. Every other volume is answered with why it stays; a named volume is data and is never taken, " +
|
||||||
|
"and no volume can be named. Each is removed on its own, never forced; the answer has the count and names removed. With sizes true the answer has each volume's size and the bytes freed; docker's size listing is slow on many volumes, so it is asked for, and when it does not answer the call answers without it. At most limit are removed per call, in batches. " +
|
||||||
|
"A dry run by default; dry_run false needs why. Replaces docker volume rm and docker volume prune. (a)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"},
|
||||||
|
"why": map[string]any{"type": "string", "description": "why: required when dry_run is false"},
|
||||||
|
"created_before": map[string]any{"type": "string", "description": "RFC 3339 time: only volumes created before it (default: any age)"},
|
||||||
|
"sizes": map[string]any{"type": "boolean", "description": "also read each volume's size and the bytes freed (default false: docker walks every volume for it and on a machine with very many may not answer; the call then answers without sizes)"},
|
||||||
|
"limit": map[string]any{"type": "integer", "description": "how many to remove in one call (default 200, at most 2000); the answer says how many remain, call again for them"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
for _, k := range []string{"name", "names", "volume", "volumes"} {
|
||||||
|
if _, ok := args[k]; ok {
|
||||||
|
return nil, fmt.Errorf("%s: this tool takes no volume by name; it removes only unmounted anonymous volumes", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
limit, err := bounded(args, "limit", 200, 2000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ask := RemoveVolumesAsk{DryRun: flag(args, "dry_run", true), Why: optional(args, "why"), Sizes: flag(args, "sizes", false), Limit: limit}
|
||||||
|
if s := optional(args, "created_before"); s != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("created_before must be an RFC 3339 time such as 2026-10-01T00:00:00Z")
|
||||||
|
}
|
||||||
|
ask.CreatedBefore = &t
|
||||||
|
}
|
||||||
|
return c.RemoveAnonymousVolumes(ctx, ask)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "docker_events",
|
Name: "docker_events",
|
||||||
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked; " +
|
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked; " +
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// of the file could not be read by anything on the mesh.
|
||||||
func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachines(t *testing.T) {
|
func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachines(t *testing.T) {
|
||||||
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch novox.internal\noptions timeout:1 attempts:2\n"
|
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch node-c.internal\noptions timeout:1 attempts:2\n"
|
||||||
f := (&fake{}).on("docker cp route-proxy:/etc/resolv.conf -",
|
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)})
|
Ran{Stdout: archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeReg, Mode: 0o644}, inside)})
|
||||||
c := client(f, 1000)
|
c := client(f, 1000)
|
||||||
@@ -47,7 +47,7 @@ func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachine
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
rc := got["resolv_conf"].(ResolvConf)
|
rc := got["resolv_conf"].(ResolvConf)
|
||||||
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "novox.internal" ||
|
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "node-c.internal" ||
|
||||||
strings.Join(rc.Options, ",") != "timeout:1,attempts:2" || rc.Raw != inside {
|
strings.Join(rc.Options, ",") != "timeout:1,attempts:2" || rc.Raw != inside {
|
||||||
t.Fatalf("parsed: %+v", rc)
|
t.Fatalf("parsed: %+v", rc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"docker_disk_usage",
|
"docker_disk_usage",
|
||||||
"docker_networks",
|
"docker_networks",
|
||||||
"docker_volumes",
|
"docker_volumes",
|
||||||
|
"docker_volumes_remove_anonymous",
|
||||||
"docker_events",
|
"docker_events",
|
||||||
"docker_daemon_config",
|
"docker_daemon_config",
|
||||||
"docker_unlabelled",
|
"docker_unlabelled",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
|
|||||||
withBus(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"`})
|
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("LOG", filepath.Join(bin, "log"))
|
||||||
t.Setenv("MESH_NODE", "g14")
|
t.Setenv("MESH_NODE", "node-b")
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var said []map[string]any
|
var said []map[string]any
|
||||||
before := emit
|
before := emit
|
||||||
@@ -58,7 +58,7 @@ func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
|
|||||||
time.Sleep(20 * time.Millisecond)
|
time.Sleep(20 * time.Millisecond)
|
||||||
}
|
}
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "g14" {
|
if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "node-b" {
|
||||||
t.Fatalf("said %v", said)
|
t.Fatalf("said %v", said)
|
||||||
}
|
}
|
||||||
said = nil
|
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
|
// **A bundle that started says so**, so the operator channel shows again the answers lost with the one
|
||||||
// before.
|
// before.
|
||||||
func TestABundleThatStartedSaysSo(t *testing.T) {
|
func TestABundleThatStartedSaysSo(t *testing.T) {
|
||||||
t.Setenv("MESH_NODE", "g14")
|
t.Setenv("MESH_NODE", "node-b")
|
||||||
var said []string
|
var said []string
|
||||||
before := emit
|
before := emit
|
||||||
emit = func(event string, body any) error {
|
emit = func(event string, body any) error {
|
||||||
@@ -105,7 +105,7 @@ func TestABundleThatStartedSaysSo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { emit = before })
|
t.Cleanup(func() { emit = before })
|
||||||
sayStarted()
|
sayStarted()
|
||||||
if len(said) != 1 || said[0] != "started map[machine:g14]" {
|
if len(said) != 1 || said[0] != "started map[machine:node-b]" {
|
||||||
t.Fatalf("%v", said)
|
t.Fatalf("%v", said)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,8 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s := strings.ToLower(string(raw))
|
s := strings.ToLower(string(raw))
|
||||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
// 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"} {
|
||||||
if strings.Contains(s, never) {
|
if strings.Contains(s, never) {
|
||||||
t.Errorf("module.json names %q", 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
|
// 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),
|
// `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
|
// 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 ace on 2026-10-13: every jail held the list
|
// reading of the slashes after `file:`, never the text (seen on node-a on 2026-10-13: every jail held the list
|
||||||
// and the watch said none did).
|
// and the watch said none did).
|
||||||
func isOurList(word string) bool {
|
func isOurList(word string) bool {
|
||||||
path, ok := strings.CutPrefix(strings.TrimSpace(word), "file:")
|
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
|
// 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 ace's daemon printed it on
|
// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as node-a's daemon printed it on
|
||||||
// 2026-10-13.
|
// 2026-10-13.
|
||||||
const asPrinted = "file://var/lib/mesh-fail2ban/own-addresses"
|
const asPrinted = "file://var/lib/mesh-fail2ban/own-addresses"
|
||||||
|
|
||||||
|
|||||||
@@ -259,7 +259,8 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s := strings.ToLower(string(raw))
|
s := strings.ToLower(string(raw))
|
||||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
// 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"} {
|
||||||
if strings.Contains(s, never) {
|
if strings.Contains(s, never) {
|
||||||
t.Errorf("module.json names %q", never)
|
t.Errorf("module.json names %q", never)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
|||||||
t.Errorf("the Go tools bundle: %v", b)
|
t.Errorf("the Go tools bundle: %v", b)
|
||||||
}
|
}
|
||||||
s := strings.ToLower(raw)
|
s := strings.ToLower(raw)
|
||||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
// 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"} {
|
||||||
if strings.Contains(s, never) {
|
if strings.Contains(s, never) {
|
||||||
t.Errorf("module.json names %q", never)
|
t.Errorf("module.json names %q", never)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// **A consumer that cannot be bound yet is bound again, never fatal** (novox/hq issue 1793). When the tool runtime
|
||||||
|
// starts this bundle, the runtime's answer to "bind this module's consumer" can be "no responders" for a few seconds (the
|
||||||
|
// runtime restarted by a delivery, the bus not yet serving the stream). That rejection, left unhandled at the top of
|
||||||
|
// index.ts, ended the bundle: nothing heard `mesh-controller.checked` again, and a verdict that landed later never
|
||||||
|
// reached the forge's statuses. A bind that fails is said and asked again with a growing wait until it holds.
|
||||||
|
|
||||||
|
/** How long to wait before the nth new attempt (1-based): 2 s, 5 s, 10 s, then every 30 s. */
|
||||||
|
export function bindWait(attempt: number): number {
|
||||||
|
return [2_000, 5_000, 10_000][attempt - 1] ?? 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds with `bind`, asking again after each failure, until it holds. `sleep` is the wait; `say` how a failure is said.
|
||||||
|
* Never rejects; resolves once the bind holds.
|
||||||
|
*/
|
||||||
|
export async function bindUntilHeld(what: string, bind: () => Promise<unknown>,
|
||||||
|
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||||
|
say: (line: string) => void = (line) => console.error(line)): Promise<void> {
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await bind();
|
||||||
|
if (attempt > 1) say(`[gitea] ${what} is bound after ${attempt} attempts`);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
const wait = bindWait(attempt);
|
||||||
|
say(`[gitea] ${what} could not be bound (${err instanceof Error ? err.message : String(err)}); asking again in ${wait / 1000}s`);
|
||||||
|
await sleep(wait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **Starts the binds without making the bundle wait for them** (novox/hq issue 1793). The launcher imports the bundle
|
||||||
|
* and then answers the runtime's handshake, which the runtime gives 10 s and then kills the bundle without starting it
|
||||||
|
* again; a bind awaited at the top of the module that stays refused longer ends the bundle all the same. So this
|
||||||
|
* returns at once: the binds run in the order given, each until it holds, and `done` is called after the last.
|
||||||
|
*/
|
||||||
|
export function bindInBackground(binds: [what: string, bind: () => Promise<unknown>][], done: () => void,
|
||||||
|
sleep?: (ms: number) => Promise<void>, say?: (line: string) => void): void {
|
||||||
|
void (async () => {
|
||||||
|
for (const [what, bind] of binds) await bindUntilHeld(what, bind, sleep, say);
|
||||||
|
done();
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -179,7 +179,7 @@ func newTeamRig(t *testing.T) *teamRig {
|
|||||||
svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222,
|
svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222,
|
||||||
Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner, Log: func(string, ...any) {}})
|
Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner, Log: func(string, ...any) {}})
|
||||||
svc.loaded = true
|
svc.loaded = true
|
||||||
svc.loadedFrom["mesh-build-go"], svc.loadedFrom["mesh-build-node"] = true, true
|
svc.loadedFrom["mesh-build-go"], svc.loadedFrom["mesh-build-node"], svc.loadedFrom["mesh-build-image"] = true, true, true
|
||||||
return &teamRig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner}
|
return &teamRig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@ func TestABuildModulesEntryWaitsForItsOwnBuilderKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTheKindOfAModulesKeyAndItsForgeUser(t *testing.T) {
|
func TestTheKindOfAModulesKeyAndItsForgeUser(t *testing.T) {
|
||||||
for module, want := range map[string]string{"mesh-build-go": "nox-builder", "mesh-build-node": "nox-builder", "ssh-client": "nox-agent", "anything-else": "nox-agent"} {
|
for module, want := range map[string]string{"mesh-build-go": "nox-builder", "mesh-build-node": "nox-builder", "mesh-build-image": "nox-builder", "ssh-client": "nox-agent", "anything-else": "nox-agent"} {
|
||||||
got, err := (&Service{}).valuesForUser("ace", module)
|
got, err := (&Service{}).valuesForUser("ace", module)
|
||||||
if err != nil || got != want {
|
if err != nil || got != want {
|
||||||
t.Errorf("%s: %q, %v; want %q", module, got, err, want)
|
t.Errorf("%s: %q, %v; want %q", module, got, err, want)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func newWatched(t *testing.T) *watched {
|
|||||||
r.svc.OnKeys = func(string) { n++ }
|
r.svc.OnKeys = func(string) { n++ }
|
||||||
r.svc.cfg.Log = func(f string, a ...any) { mu.Lock(); logs = append(logs, fmt.Sprintf(f, a...)); mu.Unlock() }
|
r.svc.cfg.Log = func(f string, a ...any) { mu.Lock(); logs = append(logs, fmt.Sprintf(f, a...)); mu.Unlock() }
|
||||||
w := &watched{teamRig: r, sources: map[string]*fakeKeys{}, passes: &n, logs: &logs, mu: mu}
|
w := &watched{teamRig: r, sources: map[string]*fakeKeys{}, passes: &n, logs: &logs, mu: mu}
|
||||||
for _, module := range []string{"ssh-client", "mesh-build-go", "mesh-build-node"} {
|
for _, module := range []string{"ssh-client", "mesh-build-go", "mesh-build-node", "mesh-build-image"} {
|
||||||
w.sources[module] = &fakeKeys{}
|
w.sources[module] = &fakeKeys{}
|
||||||
if err := r.svc.WatchSource(context.Background(), module, w.sources[module]); err != nil {
|
if err := r.svc.WatchSource(context.Background(), module, w.sources[module]); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -154,7 +154,7 @@ func TestTheManifestReadsTheBuildModulesKeys(t *testing.T) {
|
|||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys"} {
|
for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", "mesh-build-image.keys"} {
|
||||||
if !slices.Contains(m.Reads, state) {
|
if !slices.Contains(m.Reads, state) {
|
||||||
t.Errorf("gitea's reads %v lack %q", m.Reads, state)
|
t.Errorf("gitea's reads %v lack %q", m.Reads, state)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -47,6 +47,7 @@ import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateS
|
|||||||
import { GateWriter, clonedAs, type BuildStarted } from "./gate.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 { 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";
|
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
|
||||||
|
import { bindInBackground } from "./bind.js";
|
||||||
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
|
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
|
||||||
|
|
||||||
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||||
@@ -590,15 +591,18 @@ if (gitea) {
|
|||||||
tick(() => pollRepos(client), 60_000);
|
tick(() => pollRepos(client), 60_000);
|
||||||
tick(() => pollMerged(client), () => pace.every());
|
tick(() => pollMerged(client), () => pace.every());
|
||||||
tick(() => pollPulls(client), () => pace.every());
|
tick(() => pollPulls(client), () => pace.every());
|
||||||
await on("mesh-controller.checked", (event) => {
|
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
|
||||||
const c = (event.body ?? {}) as Checked;
|
// handshake in 10 s, so the import must end while a refused bind is still asked again.
|
||||||
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
|
bindInBackground([
|
||||||
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
|
["mesh-controller.checked", () => on("mesh-controller.checked", (event) => {
|
||||||
if (!c.group && c.owner && c.repo && c.commit) {
|
const c = (event.body ?? {}) as Checked;
|
||||||
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
|
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
|
||||||
}
|
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
|
||||||
return setVerdict(client, event);
|
if (!c.group && c.owner && c.repo && c.commit) {
|
||||||
});
|
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
|
||||||
await on("node-build-agent.started", buildStarted);
|
}
|
||||||
console.log("[gitea] watching for new repositories and merged pull requests");
|
return setVerdict(client, event);
|
||||||
|
})],
|
||||||
|
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
|
||||||
|
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ const BuilderUser = "nox-builder"
|
|||||||
|
|
||||||
// BuildModules are the modules whose keys are the builders' (kind builder), by their name, which is also the name of
|
// BuildModules are the modules whose keys are the builders' (kind builder), by their name, which is also the name of
|
||||||
// the account each runs as (ADR 0466 §1).
|
// the account each runs as (ADR 0466 §1).
|
||||||
var BuildModules = []string{"mesh-build-go", "mesh-build-node"}
|
var BuildModules = []string{"mesh-build-go", "mesh-build-node", "mesh-build-image"}
|
||||||
|
|
||||||
// KindOfModule is the kind of key a consumer module's entry is made for: builder for a build module, machine for any
|
// KindOfModule is the kind of key a consumer module's entry is made for: builder for a build module, machine for any
|
||||||
// other (ssh-client's).
|
// other (ssh-client's).
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func TestTheBuildModulesHaveOneForgeUserAndTheMachinesKeepTheirs(t *testing.T) {
|
|||||||
if ForgeUser("Not A Node", "builder") != "" {
|
if ForgeUser("Not A Node", "builder") != "" {
|
||||||
t.Errorf("a name no account could be named for got a user")
|
t.Errorf("a name no account could be named for got a user")
|
||||||
}
|
}
|
||||||
for module, want := range map[string]string{"mesh-build-go": "builder", "mesh-build-node": "builder", "ssh-client": "machine", "": "machine"} {
|
for module, want := range map[string]string{"mesh-build-go": "builder", "mesh-build-node": "builder", "mesh-build-image": "builder", "ssh-client": "machine", "": "machine"} {
|
||||||
if got := KindOfModule(module); got != want {
|
if got := KindOfModule(module); got != want {
|
||||||
t.Errorf("KindOfModule(%q) = %s, want %s", module, got, want)
|
t.Errorf("KindOfModule(%q) = %s, want %s", module, got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { mergeableUnder, newestByContext } from "./checks.js";
|
|||||||
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
|
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
|
||||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
|
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
|
||||||
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
|
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
|
||||||
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk } from "./trunk.js";
|
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
|
||||||
|
|
||||||
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
|
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
|
||||||
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
|
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
|
||||||
@@ -117,6 +117,14 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
|||||||
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
|
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
|
||||||
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
|
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
|
||||||
const head = twelve(pull.head_sha), now = twelve(trunkHead);
|
const head = twelve(pull.head_sha), now = twelve(trunkHead);
|
||||||
|
// **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 ` +
|
||||||
|
`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) {
|
if (at.onItsWay) {
|
||||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
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[CHECK_CONTEXT]?.description}), so it is not ` +
|
||||||
|
|||||||
@@ -542,6 +542,7 @@
|
|||||||
"mesh-controller.assignments",
|
"mesh-controller.assignments",
|
||||||
"ssh-client.keys",
|
"ssh-client.keys",
|
||||||
"mesh-build-go.keys",
|
"mesh-build-go.keys",
|
||||||
"mesh-build-node.keys"
|
"mesh-build-node.keys",
|
||||||
|
"mesh-build-image.keys"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ function repoWrong(repo: Layer | undefined, facts: RepoCheckFacts): boolean {
|
|||||||
return !!repo && repoStatus(repo, facts).state !== "success";
|
return !!repo && repoStatus(repo, facts).state !== "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a group member's gate says while the group's composed check is the one that judges it: the member's own check can say
|
||||||
|
* nothing more of it, so asking that check again brings no verdict that names a trunk (novox/hq issue 1106). */
|
||||||
|
export const WAITS_FOR_COMPOSED = "waits for its group's composed check";
|
||||||
|
|
||||||
/** The forge's status for the gate. */
|
/** The forge's status for the gate. */
|
||||||
export function statusFor(c: Checked): CommitStatus {
|
export function statusFor(c: Checked): CommitStatus {
|
||||||
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
||||||
@@ -260,7 +264,7 @@ export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: st
|
|||||||
// set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it.
|
// set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it.
|
||||||
const out: CommitStatus[] = [];
|
const out: CommitStatus[] = [];
|
||||||
if (!(gateNow ?? "").includes(" composed: ")) {
|
if (!(gateNow ?? "").includes(" composed: ")) {
|
||||||
out.push({ state: "pending", context: CHECK_CONTEXT, description: "waits for its group's composed check" });
|
out.push({ state: "pending", context: CHECK_CONTEXT, description: WAITS_FOR_COMPOSED });
|
||||||
}
|
}
|
||||||
const repo = c["repo-check"];
|
const repo = c["repo-check"];
|
||||||
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
|
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// **A consumer that cannot be bound yet is bound again** (novox/hq issue 1793): the runtime's "no responders" at the
|
||||||
|
// bundle's start, seen 2026-10-22 00:28:37 on mesh-catalog#427's verdict, ended the bundle and the verdict was never
|
||||||
|
// written on the forge.
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { bindInBackground, bindUntilHeld, bindWait } from "../dist/bind.js";
|
||||||
|
|
||||||
|
test("a bind refused with no responders is asked again until it holds, and says so", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const waits: number[] = [];
|
||||||
|
const said: string[] = [];
|
||||||
|
await bindUntilHeld("mesh-controller.checked", async () => {
|
||||||
|
calls++;
|
||||||
|
if (calls < 4) throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request");
|
||||||
|
}, async (ms) => { waits.push(ms); }, (l) => said.push(l));
|
||||||
|
assert.equal(calls, 4);
|
||||||
|
assert.deepEqual(waits, [2_000, 5_000, 10_000]);
|
||||||
|
assert.equal(said.length, 4);
|
||||||
|
assert.match(said[0], /no responders available for request.*asking again in 2s/);
|
||||||
|
assert.match(said[3], /is bound after 4 attempts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bind that holds the first time says nothing and waits for nothing", async () => {
|
||||||
|
const said: string[] = [];
|
||||||
|
await bindUntilHeld("x", async () => {}, async () => assert.fail("waited"), (l) => said.push(l));
|
||||||
|
assert.deepEqual(said, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the wait grows to 30 s and stays", () => {
|
||||||
|
assert.deepEqual([1, 2, 3, 4, 50].map(bindWait), [2_000, 5_000, 10_000, 30_000, 30_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the binds start without being waited for: the caller returns while a bind keeps refusing, and they go in order", async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
let finished = false;
|
||||||
|
let refusals = 0;
|
||||||
|
const gate = new Promise<void>((r) => setTimeout(r, 0));
|
||||||
|
bindInBackground([
|
||||||
|
["first", async () => { refusals++; if (refusals < 3) throw new Error("nats: no responders available for request"); order.push("first"); }],
|
||||||
|
["second", async () => { order.push("second"); }],
|
||||||
|
], () => { finished = true; }, async () => { await gate; }, () => {});
|
||||||
|
// Returned at once, with the first bind refused and nothing done.
|
||||||
|
assert.equal(finished, false);
|
||||||
|
assert.deepEqual(order, []);
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
assert.deepEqual(order, ["first", "second"]);
|
||||||
|
assert.equal(finished, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bind that never holds does not stop the caller from returning", () => {
|
||||||
|
let returned = false;
|
||||||
|
bindInBackground([["x", async () => { throw new Error("no responders"); }]], () => assert.fail("done"),
|
||||||
|
() => new Promise(() => {}), () => {});
|
||||||
|
returned = true;
|
||||||
|
assert.equal(returned, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// **The bundle's import ends while a bind is refused** (novox/hq issue 1793). The launcher imports index.js and then answers
|
||||||
|
// node-tools' handshake, which has 10 s before the bundle is killed and never started again. A bind awaited at the top
|
||||||
|
// of index.ts, refused for longer, ended the bundle at 00:28:37 on 2026-10-22. Run in a child: the polls' timers keep a
|
||||||
|
// process alive, and the child says "imported" and exits as soon as the import resolves.
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
test("importing the bundle resolves while the broker keeps refusing the binds", async () => {
|
||||||
|
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
|
||||||
|
const script = `
|
||||||
|
const { useBroker } = await import("@novox/mesh-sdk/messaging");
|
||||||
|
useBroker(() => ({
|
||||||
|
subscribe: async () => { throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request"); },
|
||||||
|
publish: async () => {}, request: async () => { throw new Error("no responders"); },
|
||||||
|
handle: async () => () => {}, close: async () => {},
|
||||||
|
}));
|
||||||
|
await import(${JSON.stringify(index)});
|
||||||
|
console.log("imported");
|
||||||
|
process.exit(0);`;
|
||||||
|
const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_URL: "http://127.0.0.1:1" };
|
||||||
|
for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE"]) delete env[k];
|
||||||
|
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let out = "";
|
||||||
|
let err = "";
|
||||||
|
child.stdout.on("data", (d) => (out += d));
|
||||||
|
child.stderr.on("data", (d) => (err += d));
|
||||||
|
const ended = new Promise<void>((r) => child.on("exit", () => r()));
|
||||||
|
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
|
||||||
|
await ended;
|
||||||
|
clearTimeout(timer);
|
||||||
|
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
|
||||||
|
});
|
||||||
@@ -339,6 +339,30 @@ test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk
|
|||||||
assert.equal(state.emitted.length, 4, "a merge asks no check");
|
assert.equal(state.emitted.length, 4, "a merge asks no check");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// **A group member whose gate waits for its group's composed check is not announced again by the merge tool** (novox/hq issue
|
||||||
|
// 1106): the member's own check can only say "composed" again, so each refusal's ask brought a verdict that names no trunk and
|
||||||
|
// the next merge was refused the same way, for ever. The tool says whose verdict is awaited and asks nothing.
|
||||||
|
test("gitea_merge_pull_request asks no check of a member that waits for its group's composed check", async () => {
|
||||||
|
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
||||||
|
state.trunk = TRUNK;
|
||||||
|
const before = state.emitted.length;
|
||||||
|
state.statuses = [{ context: "mesh/merge-gate", state: "pending", description: "waits for its group's composed check" },
|
||||||
|
{ context: "mesh/repo-check", state: "success", description: "pass: y" }];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await assert.rejects(merge(), (err: Error) => {
|
||||||
|
assert.match(err.message, /waits for its group's composed check/);
|
||||||
|
assert.match(err.message, /mesh-delivery asks it, this tool asks no check/);
|
||||||
|
assert.match(err.message, /Nothing was merged/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(state.emitted.length, before, "no check is announced again and again");
|
||||||
|
assert.equal(state.merged, false);
|
||||||
|
// Once the composed verdict names the member's unmoved trunk, it merges.
|
||||||
|
state.statuses = judgedAt(TRUNK);
|
||||||
|
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||||
|
});
|
||||||
|
|
||||||
// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head,
|
// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head,
|
||||||
// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was.
|
// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was.
|
||||||
test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => {
|
test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
|
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
|
||||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
|
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, WAITS_FOR_COMPOSED } from "./pulls.js";
|
||||||
|
|
||||||
/** A commit as a verdict names it: its first twelve characters. */
|
/** A commit as a verdict names it: its first twelve characters. */
|
||||||
export function twelve(sha: string | undefined): string {
|
export function twelve(sha: string | undefined): string {
|
||||||
@@ -58,6 +58,11 @@ export function checkOnItsWay(gate: HeadCheck | undefined): boolean {
|
|||||||
return d.startsWith("waiting in the build queue") || d.startsWith("building on ") || d.startsWith("checking again");
|
return d.startsWith("waiting in the build queue") || d.startsWith("building on ") || d.startsWith("checking again");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a gate says it waits for its group's composed check. */
|
||||||
|
export function waitsForComposed(gate: HeadCheck | undefined): boolean {
|
||||||
|
return !!gate && gate.state === "pending" && (gate.description ?? "").startsWith(WAITS_FOR_COMPOSED);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The statuses a trunk's move puts back to pending on one open pull request's head, from what the forge holds on it
|
* The statuses a trunk's move puts back to pending on one open pull request's head, from what the forge holds on it
|
||||||
* now: none when it has no merge check yet, when a check is pending (on its way, or already out of date — its
|
* now: none when it has no merge check yet, when a check is pending (on its way, or already out of date — its
|
||||||
|
|||||||
@@ -257,7 +257,8 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s := strings.ToLower(string(raw))
|
s := strings.ToLower(string(raw))
|
||||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
// 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"} {
|
||||||
if strings.Contains(s, never) {
|
if strings.Contains(s, never) {
|
||||||
t.Errorf("module.json names %q", 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
|
// **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
|
// 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
|
// 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 ace, which mosquitto keeps. If Home
|
// Assistant keeps the login it has — the carried `luffy` on node-a, which mosquitto keeps. If Home
|
||||||
// Assistant's own connection test refuses the new settings, the flow saves nothing, and the step
|
// 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
|
// 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.
|
// 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:
|
// 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.
|
// 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
|
// 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: ace's entries
|
// 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 `ace.internal:<port>`, one Sonarr either way.
|
// say `127.0.0.1:<port>` and the binding says `node-a.internal:<port>`, one Sonarr either way.
|
||||||
// 6. Anything else is refused, loudly, with what the operator can do; nothing is removed.
|
// 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.
|
// 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
|
// 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 ace runs).
|
// ghcr.io/home-assistant/home-assistant 2026.9.3, the build node-a runs).
|
||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -21,7 +21,7 @@ const PWD_NOT_CHANGED = "__**password_not_changed**__";
|
|||||||
const MINTED = "mesh-minted-password";
|
const MINTED = "mesh-minted-password";
|
||||||
|
|
||||||
function mqttBinding(): Binding {
|
function mqttBinding(): Binding {
|
||||||
return { provision: "mqtt-topic", from: "ace", at: "ace.internal", as: "mesh_ace_hass", serves: { scheme: "mqtt", port: 1883 } };
|
return { provision: "mqtt-topic", from: "node-a", at: "node-a.internal", as: "mesh_node-a_hass", serves: { scheme: "mqtt", port: 1883 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The MQTT reconfigure form as Home Assistant serializes it, pre-filled from an entry. */
|
/** 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) };
|
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_ace_hass" && pass === MINTED ? 0 : 5, suback });
|
const takes = (suback = 0): Probe => async (_h, _p, user, pass) => ({ connack: user === "mesh_node-a_hass" && pass === MINTED ? 0 : 5, suback });
|
||||||
|
|
||||||
const aceMqttEntry = () => ({
|
const nodeAMqttEntry = () => ({
|
||||||
entry_id: "7d1e", domain: "mqtt", state: "loaded",
|
entry_id: "7d1e", domain: "mqtt", state: "loaded",
|
||||||
data: { broker: "127.0.0.1", port: 1883, protocol: "5", username: "luffy", password: "luffys-password" },
|
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 () => {
|
test("mqtt: the broker is asked first; a login it does not take is never written", async () => {
|
||||||
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
|
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] } });
|
||||||
const out = await reconcileMqtt({ hass: f.hass, probe: async () => ({ connack: 5 }), marks: memoryMarks() }, mqttBinding(), MINTED);
|
const out = await reconcileMqtt({ hass: f.hass, probe: async () => ({ connack: 5 }), marks: memoryMarks() }, mqttBinding(), MINTED);
|
||||||
assert.equal(out.result, "refused");
|
assert.equal(out.result, "refused");
|
||||||
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_ace_hass/);
|
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_node-a_hass/);
|
||||||
assert.deepEqual(f.calls, []); // Home Assistant not even asked
|
assert.deepEqual(f.calls, []); // Home Assistant not even asked
|
||||||
assert.equal(f.entries.mqtt[0].data.username, "luffy");
|
assert.equal(f.entries.mqtt[0].data.username, "luffy");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mqtt: ace's entry (127.0.0.1, luffy) is moved to the bound broker and login, every other setting kept", async () => {
|
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: [aceMqttEntry()] } });
|
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] } });
|
||||||
const marks = memoryMarks();
|
const marks = memoryMarks();
|
||||||
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), `${MINTED}\n`);
|
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(out, { what: "mqtt", result: "written", fields: ["broker", "username", "password"] });
|
||||||
assert.deepEqual(f.entries.mqtt[0].data, {
|
assert.deepEqual(f.entries.mqtt[0].data, {
|
||||||
broker: "ace.internal", port: 1883, protocol: "5", username: "mesh_ace_hass", password: MINTED,
|
broker: "node-a.internal", port: 1883, protocol: "5", username: "mesh_node-a_hass", password: MINTED,
|
||||||
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
|
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
|
||||||
});
|
});
|
||||||
assert.ok(marks.store.get("mqtt"));
|
assert.ok(marks.store.get("mqtt"));
|
||||||
@@ -175,7 +175,7 @@ test("mqtt: ace's entry (127.0.0.1, luffy) is moved to the bound broker and logi
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("mqtt: a new password alone is written (the digest tells)", async () => {
|
test("mqtt: a new password alone is written (the digest tells)", async () => {
|
||||||
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
|
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] } });
|
||||||
const marks = memoryMarks();
|
const marks = memoryMarks();
|
||||||
await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
|
await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
|
||||||
const rotated: Probe = async () => ({ connack: 0, suback: 0 });
|
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 () => {
|
test("mqtt: Home Assistant's own connection test refusing saves nothing and fails loudly", async () => {
|
||||||
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] }, accepts: () => false });
|
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] }, accepts: () => false });
|
||||||
const marks = memoryMarks();
|
const marks = memoryMarks();
|
||||||
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
|
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
|
||||||
assert.equal(out.result, "refused");
|
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);
|
const out = await reconcileMqtt({ hass: f.hass, probe: takes(0x80), marks: memoryMarks() }, mqttBinding(), MINTED);
|
||||||
assert.equal(out.result, "written");
|
assert.equal(out.result, "written");
|
||||||
assert.match((out as { note?: string }).note ?? "", /may not subscribe to homeassistant\/#/);
|
assert.match((out as { note?: string }).note ?? "", /may not subscribe to homeassistant\/#/);
|
||||||
assert.equal(f.entries.mqtt[0].data.broker, "ace.internal");
|
assert.equal(f.entries.mqtt[0].data.broker, "node-a.internal");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mqtt: two entries, or a binding without a port, are refused rather than guessed", async () => {
|
test("mqtt: two entries, or a binding without a port, are refused rather than guessed", async () => {
|
||||||
const f = fakeHass({ entries: { mqtt: [aceMqttEntry(), { ...aceMqttEntry(), entry_id: "other" }] } });
|
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry(), { ...nodeAMqttEntry(), entry_id: "other" }] } });
|
||||||
assert.equal((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, mqttBinding(), MINTED)).result, "refused");
|
assert.equal((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, mqttBinding(), MINTED)).result, "refused");
|
||||||
const noPort = { ...mqttBinding(), serves: {} };
|
const noPort = { ...mqttBinding(), serves: {} };
|
||||||
assert.match(((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, noPort, MINTED)) as { problem: string }).problem, /no usable port/);
|
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 SONARR = APPS.find((a) => a.domain === "sonarr") as ServarrApp;
|
||||||
const RADARR = APPS.find((a) => a.domain === "radarr") as ServarrApp;
|
const RADARR = APPS.find((a) => a.domain === "radarr") as ServarrApp;
|
||||||
const KEY = "the-apps-own-key";
|
const KEY = "the-apps-own-key";
|
||||||
const servarrBinding = (port: number, at = "ace.internal"): Binding => ({ provision: "sonarr-api", from: "ace", at, as: "mesh_ace_hass", serves: { scheme: "http", port, "url-base": "" } });
|
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": "" } });
|
||||||
|
|
||||||
/** One running Sonarr, answering on several addresses (127.0.0.1 and ace.internal are one host). */
|
/** One running Sonarr, answering on several addresses (127.0.0.1 and node-a.internal are one host). */
|
||||||
function apps(instances: Record<string, { startTime: string }>): Http & { asked: string[] } {
|
function apps(instances: Record<string, { startTime: string }>): Http & { asked: string[] } {
|
||||||
const asked: string[] = [];
|
const asked: string[] = [];
|
||||||
return {
|
return {
|
||||||
@@ -231,18 +231,18 @@ function apps(instances: Record<string, { startTime: string }>): Http & { asked:
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const oneSonarr = () => apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "t1" } });
|
const oneSonarr = () => apps({ "node-a.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 } });
|
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 () => {
|
test("servarr: the mesh's minted key is never written; the remedy names the accept", async () => {
|
||||||
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] } });
|
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] } });
|
||||||
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), "minted-by-the-mesh");
|
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), "minted-by-the-mesh");
|
||||||
assert.equal(out.result, "refused");
|
assert.equal(out.result, "refused");
|
||||||
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider ace/);
|
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider node-a/);
|
||||||
assert.deepEqual(f.calls, []);
|
assert.deepEqual(f.calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("servarr: ace's entry at 127.0.0.1 reaches the same Sonarr the mesh bound at ace.internal — left, and said", async () => {
|
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 () => {
|
||||||
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
|
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);
|
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
|
||||||
assert.equal(out.result, "equivalent");
|
assert.equal(out.result, "equivalent");
|
||||||
@@ -250,13 +250,13 @@ test("servarr: ace's entry at 127.0.0.1 reaches the same Sonarr the mesh bound a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("servarr: an entry already at the bound URL is unchanged", async () => {
|
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://ace.internal:8989" }] });
|
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://node-a.internal:8989" }] });
|
||||||
assert.deepEqual(await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY), { what: "sonarr", result: "unchanged" });
|
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 () => {
|
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 f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
|
||||||
const two = apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
|
const two = apps({ "node-a.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
|
||||||
const out = await reconcileApp({ hass: f.hass, http: two }, SONARR, servarrBinding(8989), KEY);
|
const out = await reconcileApp({ hass: f.hass, http: two }, SONARR, servarrBinding(8989), KEY);
|
||||||
assert.equal(out.result, "refused");
|
assert.equal(out.result, "refused");
|
||||||
assert.match((out as { problem: string }).problem, /never does/);
|
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 f = fakeHass({ entries: {} });
|
||||||
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
|
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
|
||||||
assert.deepEqual(out, { what: "sonarr", result: "written", fields: ["entry"] });
|
assert.deepEqual(out, { what: "sonarr", result: "written", fields: ["entry"] });
|
||||||
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:8989", api_key: KEY, more_options: { verify_ssl: false } });
|
assert.deepEqual(f.submitted[0], { url: "http://node-a.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 () => {
|
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] },
|
entries: { radarr: [entry] },
|
||||||
reauth: [{ flow_id: "r1", handler: "radarr", step_id: "reauth_confirm", context: { source: "reauth", entry_id: "1955" } }],
|
reauth: [{ flow_id: "r1", handler: "radarr", step_id: "reauth_confirm", context: { source: "reauth", entry_id: "1955" } }],
|
||||||
});
|
});
|
||||||
const radarr = apps({ "ace.internal:7878": { startTime: "t" } });
|
const radarr = apps({ "node-a.internal:7878": { startTime: "t" } });
|
||||||
const out = await reconcileApp({ hass: f.hass, http: radarr }, RADARR, { ...servarrBinding(7878), provision: "radarr-api" }, KEY);
|
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(out, { what: "radarr", result: "written", fields: ["api_key", "url"] });
|
||||||
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:7878", api_key: KEY, verify_ssl: false });
|
assert.deepEqual(f.submitted[0], { url: "http://node-a.internal:7878", api_key: KEY, verify_ssl: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("form values: suggested first, then default, sections nested", () => {
|
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,
|
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 },
|
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
|
||||||
});
|
});
|
||||||
assert.ok(sameUrl("http://ace.internal:8989/", "http://ace.internal:8989"));
|
assert.ok(sameUrl("http://node-a.internal:8989/", "http://node-a.internal:8989"));
|
||||||
assert.ok(sameUrl("http://ACE.internal", "http://ace.internal:80"));
|
assert.ok(sameUrl("http://NODE-A.internal", "http://node-a.internal:80"));
|
||||||
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://ace.internal:8989"));
|
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://node-a.internal:8989"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -261,7 +261,8 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s := strings.ToLower(string(raw))
|
s := strings.ToLower(string(raw))
|
||||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
// 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"} {
|
||||||
if strings.Contains(s, never) {
|
if strings.Contains(s, never) {
|
||||||
t.Errorf("module.json names %q", 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";
|
const PW = "mesh-minted-password-of-forty-characters";
|
||||||
|
|
||||||
/** Grafana on ace, as the mesh hands it to the provisioner. */
|
/** Grafana on node-a, as the mesh hands it to the provisioner. */
|
||||||
function grafana(password = PW, values: Record<string, unknown> = { access: "read" }) {
|
function grafana(password = PW, values: Record<string, unknown> = { access: "read" }) {
|
||||||
return { as: "mesh_ace_grafana", password, consumer: "ace", values };
|
return { as: "mesh_node-a_grafana", password, consumer: "node-a", values };
|
||||||
}
|
}
|
||||||
/** Node-RED on ace: writes one bucket. */
|
/** Node-RED on node-a: writes one bucket. */
|
||||||
function nodered(password = PW, values: Record<string, unknown> = { access: "write", buckets: ["zurag"] }) {
|
function nodered(password = PW, values: Record<string, unknown> = { access: "write", buckets: ["zurag"] }) {
|
||||||
return { as: "mesh_ace_nodered", password, consumer: "ace", values };
|
return { as: "mesh_node-a_nodered", password, consumer: "node-a", values };
|
||||||
}
|
}
|
||||||
|
|
||||||
function only(token: string): Rec {
|
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 () => {
|
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");
|
assert.equal(await grants.ensure(grafana()), "created");
|
||||||
const a = only("mesh_ace_grafana");
|
const a = only("mesh_node-a_grafana");
|
||||||
assert.equal(a.orgID, "org1");
|
assert.equal(a.orgID, "org1");
|
||||||
assert.equal(a.status, "active");
|
assert.equal(a.status, "active");
|
||||||
assert.ok(a.description.startsWith(MARK));
|
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");
|
const made = buckets.find((b) => b.name === "printer");
|
||||||
assert.ok(made, "the missing bucket was created");
|
assert.ok(made, "the missing bucket was created");
|
||||||
assert.deepEqual(made!.retentionRules, [], "kept for ever: retention is the operator's choice");
|
assert.deepEqual(made!.retentionRules, [], "kept for ever: retention is the operator's choice");
|
||||||
assert.deepEqual(perms(only("mesh_ace_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
|
assert.deepEqual(perms(only("mesh_node-a_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
|
||||||
assert.equal(await grants.remove("mesh_ace_nodered"), "removed");
|
assert.equal(await grants.remove("mesh_node-a_nodered"), "removed");
|
||||||
assert.equal(buckets.length, 2, "withdrawing the consumer leaves every bucket and its data");
|
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 = [];
|
calls = [];
|
||||||
assert.equal(await grants.ensure(grafana()), "unchanged");
|
assert.equal(await grants.ensure(grafana()), "unchanged");
|
||||||
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
|
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
|
||||||
only("mesh_ace_grafana");
|
only("mesh_node-a_grafana");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a rotated password is set in place; a changed access remakes only the mesh's own", async () => {
|
test("a rotated password is set in place; a changed access remakes only the mesh's own", async () => {
|
||||||
await grants.ensure(nodered());
|
await grants.ensure(nodered());
|
||||||
const id = only("mesh_ace_nodered").id;
|
const id = only("mesh_node-a_nodered").id;
|
||||||
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), false);
|
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), false);
|
||||||
assert.equal(await grants.ensure(nodered("rotated-password-0123456789")), "updated");
|
assert.equal(await grants.ensure(nodered("rotated-password-0123456789")), "updated");
|
||||||
assert.equal(only("mesh_ace_nodered").id, id, "updated, not replaced");
|
assert.equal(only("mesh_node-a_nodered").id, id, "updated, not replaced");
|
||||||
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), true);
|
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), true);
|
||||||
|
|
||||||
await grants.ensure(nodered(PW, { access: "read-write", buckets: ["zurag"] }));
|
await grants.ensure(nodered(PW, { access: "read-write", buckets: ["zurag"] }));
|
||||||
assert.deepEqual(perms(only("mesh_ace_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
|
assert.deepEqual(perms(only("mesh_node-a_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
|
||||||
assert.equal(await grants.holds(nodered(PW, { access: "read-write", buckets: ["zurag"] })), true);
|
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 () => {
|
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());
|
await grants.ensure(grafana());
|
||||||
only("mesh_ace_grafana").status = "inactive";
|
only("mesh_node-a_grafana").status = "inactive";
|
||||||
assert.equal(await grants.holds(grafana()), false);
|
assert.equal(await grants.holds(grafana()), false);
|
||||||
assert.equal(await grants.ensure(grafana()), "updated");
|
assert.equal(await grants.ensure(grafana()), "updated");
|
||||||
assert.equal(await grants.holds(grafana()), true);
|
assert.equal(await grants.holds(grafana()), true);
|
||||||
|
|
||||||
only("mesh_ace_grafana").password = "somebody-else-set-this";
|
only("mesh_node-a_grafana").password = "somebody-else-set-this";
|
||||||
assert.equal(await grants.holds(grafana()), false);
|
assert.equal(await grants.holds(grafana()), false);
|
||||||
await grants.ensure(grafana());
|
await grants.ensure(grafana());
|
||||||
assert.equal(await grants.holds(grafana()), true);
|
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 () => {
|
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_ace_grafana", orgID: "org1", status: "active", description: "hand-made",
|
auths = [{ id: "theirs", token: "mesh_node-a_grafana", orgID: "org1", status: "active", description: "hand-made",
|
||||||
permissions: [{ action: "write", resource: { type: "buckets", orgID: "org1" } }], password: "their-password" }];
|
permissions: [{ action: "write", resource: { type: "buckets", orgID: "org1" } }], password: "their-password" }];
|
||||||
const before = JSON.stringify(auths);
|
const before = JSON.stringify(auths);
|
||||||
await assert.rejects(grants.ensure(grafana()), /did not make/);
|
await assert.rejects(grants.ensure(grafana()), /did not make/);
|
||||||
assert.equal(JSON.stringify(auths), before);
|
assert.equal(JSON.stringify(auths), before);
|
||||||
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
|
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
|
||||||
assert.equal(await grants.holds(grafana()), false);
|
assert.equal(await grants.holds(grafana()), false);
|
||||||
assert.equal(await grants.remove("mesh_ace_grafana"), "not ours");
|
assert.equal(await grants.remove("mesh_node-a_grafana"), "not ours");
|
||||||
assert.equal(auths.length, 1, "never deleted");
|
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 () => {
|
test("a withdrawn consumer's authorization is removed, and an absent one is not an error", async () => {
|
||||||
await grants.ensure(grafana());
|
await grants.ensure(grafana());
|
||||||
assert.equal(await grants.remove("mesh_ace_grafana"), "removed");
|
assert.equal(await grants.remove("mesh_node-a_grafana"), "removed");
|
||||||
assert.equal(auths.length, 0);
|
assert.equal(auths.length, 0);
|
||||||
assert.equal(await grants.remove("mesh_ace_grafana"), "absent");
|
assert.equal(await grants.remove("mesh_node-a_grafana"), "absent");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# kernel
|
# kernel
|
||||||
|
|
||||||
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). Today it holds one:
|
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. The rest of the row (the kernel package, microcode, boot
|
**transparent huge pages**, set per machine, and **the fallback kernel** (`linux-lts` with its headers) and
|
||||||
entries) is not built yet.
|
its place on the boot menu (novox/hq ADR 0508 §9 and §10). The default kernel's package, and microcode, are
|
||||||
|
not built yet.
|
||||||
|
|
||||||
## Why (novox/hq issue 746)
|
## Why (novox/hq issue 746)
|
||||||
|
|
||||||
@@ -35,16 +36,34 @@ under the new mode. A failed rotation is said in the unit's journal and does not
|
|||||||
rotation is made while the machine is still starting (`systemctl is-system-running` says `initializing`
|
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.
|
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
|
## Tools
|
||||||
|
|
||||||
| tool | | answers |
|
| 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) |
|
| `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
|
## Tests
|
||||||
|
|
||||||
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
|
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
|
||||||
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); and the script as the mesh
|
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh
|
||||||
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
|
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
|
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
|
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kernel_boot: which loader booted the machine, its entries, the kernels in /boot, the entry it booted and
|
||||||
|
// whether the fallback kernel is on the menu (novox/hq ADR 0508 §9, issue 1841). Reads only.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unicode/utf16"
|
||||||
|
)
|
||||||
|
|
||||||
|
const loaderSelected = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
|
||||||
|
|
||||||
|
// BootState is what kernel_boot answers.
|
||||||
|
type BootState struct {
|
||||||
|
Loader string `json:"loader"`
|
||||||
|
Entries []string `json:"entries"`
|
||||||
|
Kernels []string `json:"kernels"`
|
||||||
|
Booted string `json:"booted"`
|
||||||
|
FallbackPresent bool `json:"fallback_present"`
|
||||||
|
BootFreeKiB int64 `json:"boot_free_kib"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var grubKernel = regexp.MustCompile(`(?m)^\s*linux\s+(?:/boot)?/(vmlinuz-\S+)`)
|
||||||
|
var grubTitle = regexp.MustCompile(`(?m)^\s*menuentry\s+'([^']*)'`)
|
||||||
|
var bootImage = regexp.MustCompile(`BOOT_IMAGE=(?:/boot)?/?(\S+)`)
|
||||||
|
|
||||||
|
// Boot reads the machine, the loader the way the boot-entries script does: the loader's EFI variable means
|
||||||
|
// systemd-boot, a grub.cfg without it GRUB.
|
||||||
|
func (m *Machine) Boot() (*BootState, error) {
|
||||||
|
b := &BootState{Loader: "none", Entries: []string{}, Kernels: []string{}}
|
||||||
|
files, _ := filepath.Glob(m.path("boot/vmlinuz-*"))
|
||||||
|
for _, f := range files {
|
||||||
|
b.Kernels = append(b.Kernels, filepath.Base(f))
|
||||||
|
}
|
||||||
|
sort.Strings(b.Kernels)
|
||||||
|
hasKernel := func(name string) bool {
|
||||||
|
for _, k := range b.Kernels {
|
||||||
|
if k == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var s syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(m.path("boot"), &s); err == nil {
|
||||||
|
b.BootFreeKiB = int64(s.Bavail) * int64(s.Bsize) / 1024
|
||||||
|
}
|
||||||
|
if raw, err := os.ReadFile(m.path(loaderSelected)); err == nil {
|
||||||
|
b.Loader = "systemd-boot"
|
||||||
|
if len(raw) > 4 {
|
||||||
|
var u []uint16
|
||||||
|
for i := 4; i+1 < len(raw); i += 2 {
|
||||||
|
u = append(u, uint16(raw[i])|uint16(raw[i+1])<<8)
|
||||||
|
}
|
||||||
|
b.Booted = strings.TrimRight(string(utf16.Decode(u)), "\x00")
|
||||||
|
}
|
||||||
|
files, _ := filepath.Glob(m.path("boot/loader/entries/*.conf"))
|
||||||
|
for _, f := range files {
|
||||||
|
b.Entries = append(b.Entries, filepath.Base(f))
|
||||||
|
}
|
||||||
|
sort.Strings(b.Entries)
|
||||||
|
if raw, err := os.ReadFile(m.path("boot/loader/entries/mesh-linux-lts.conf")); err == nil {
|
||||||
|
b.FallbackPresent = strings.Contains(string(raw), "/vmlinuz-linux-lts") && hasKernel("vmlinuz-linux-lts")
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
if raw, err := os.ReadFile(m.path("boot/grub/grub.cfg")); err == nil {
|
||||||
|
b.Loader = "grub"
|
||||||
|
for _, t := range grubTitle.FindAllStringSubmatch(string(raw), -1) {
|
||||||
|
b.Entries = append(b.Entries, t[1])
|
||||||
|
}
|
||||||
|
for _, k := range grubKernel.FindAllStringSubmatch(string(raw), -1) {
|
||||||
|
if k[1] == "vmlinuz-linux-lts" && hasKernel("vmlinuz-linux-lts") {
|
||||||
|
b.FallbackPresent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd, err := m.read("proc/cmdline"); err == nil {
|
||||||
|
if i := bootImage.FindStringSubmatch(cmd); i != nil {
|
||||||
|
b.Booted = i[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kernel_boot over stand-in roots: a systemd-boot machine with and without the fallback entry, a GRUB machine
|
||||||
|
// with and without the fallback kernel in its menu, and a machine with neither (novox/hq ADR 0508 §9, issue 1841).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedVar = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
|
||||||
|
|
||||||
|
func put(t *testing.T, root, p, content string) {
|
||||||
|
t.Helper()
|
||||||
|
full := filepath.Join(root, p)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
write(t, full, content, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootOf(t *testing.T, root string) *BootState {
|
||||||
|
t.Helper()
|
||||||
|
got, err := (&Machine{Root: root}).Boot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelBootReadsASystemdBootMachineWithItsFallbackEntry(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
|
||||||
|
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
|
||||||
|
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\n")
|
||||||
|
put(t, root, "boot/vmlinuz-linux", "k")
|
||||||
|
put(t, root, "boot/vmlinuz-linux-lts", "k")
|
||||||
|
got := bootOf(t, root)
|
||||||
|
if got.Loader != "systemd-boot" || got.Booted != "arch.conf" || !got.FallbackPresent {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if len(got.Entries) != 2 || got.Entries[0] != "arch.conf" || got.Entries[1] != "mesh-linux-lts.conf" {
|
||||||
|
t.Fatalf("entries: %v", got.Entries)
|
||||||
|
}
|
||||||
|
if len(got.Kernels) != 2 || got.Kernels[0] != "vmlinuz-linux" || got.Kernels[1] != "vmlinuz-linux-lts" {
|
||||||
|
t.Fatalf("kernels: %v", got.Kernels)
|
||||||
|
}
|
||||||
|
if got.BootFreeKiB <= 0 {
|
||||||
|
t.Fatalf("free: %d", got.BootFreeKiB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelBootSaysASystemdBootMachineHasNoFallbackYet(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
|
||||||
|
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
|
||||||
|
put(t, root, "boot/vmlinuz-linux", "k")
|
||||||
|
// The entry file alone is not the fallback: the kernel it names must be in /boot.
|
||||||
|
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "linux /vmlinuz-linux-lts\n")
|
||||||
|
if got := bootOf(t, root); got.FallbackPresent {
|
||||||
|
t.Fatalf("a fallback entry without its kernel: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelBootReadsAGrubMachine(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
put(t, root, "boot/grub/grub.cfg", grubMenu)
|
||||||
|
put(t, root, "boot/vmlinuz-linux", "k")
|
||||||
|
put(t, root, "proc/cmdline", bootedCmdline)
|
||||||
|
got := bootOf(t, root)
|
||||||
|
if got.Loader != "grub" || got.FallbackPresent || got.Booted != "vmlinuz-linux" {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if len(got.Entries) != 2 || got.Entries[0] != "Arch Linux" {
|
||||||
|
t.Fatalf("entries: %v", got.Entries)
|
||||||
|
}
|
||||||
|
put(t, root, "boot/vmlinuz-linux-lts", "k")
|
||||||
|
put(t, root, "boot/grub/grub.cfg", grubMenu+"menuentry 'Arch Linux LTS' {\n\tlinux\t/boot/vmlinuz-linux-lts root=x rw\n}\n")
|
||||||
|
if got := bootOf(t, root); !got.FallbackPresent {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKernelBootSaysWhenThereIsNoLoader(t *testing.T) {
|
||||||
|
got := bootOf(t, t.TempDir())
|
||||||
|
if got.Loader != "none" || got.FallbackPresent || len(got.Entries) != 0 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The boot-entries script as the mesh writes it, run with sh over a stand-in root (novox/hq ADR 0508 §9 and
|
||||||
|
// §10, issue 1841). The fixtures are the shapes read from machines: a systemd-boot entries directory, a GRUB
|
||||||
|
// menu, a hook that copies every kernel image to the default kernel's name, and the command line a machine
|
||||||
|
// booted with.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bootedCmdline = "initrd=\\initramfs-linux.img BOOT_IMAGE=/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3 initrd=\\intel-ucode.img\n"
|
||||||
|
|
||||||
|
const defaultEntry = "title Arch Linux\nlinux /vmlinuz-linux\ninitrd /intel-ucode.img\ninitrd /initramfs-linux.img\noptions root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet\n"
|
||||||
|
|
||||||
|
const loaderConf = "default arch.conf\ntimeout 3\nconsole-mode max\n"
|
||||||
|
|
||||||
|
// grubMenu is the part of a generated grub.cfg that names kernels, for a machine holding one kernel.
|
||||||
|
const grubMenu = `menuentry 'Arch Linux' --class arch {
|
||||||
|
set root='hd0,gpt2'
|
||||||
|
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
|
||||||
|
initrd /boot/initramfs-linux.img
|
||||||
|
}
|
||||||
|
menuentry 'Arch Linux, with fallback initramfs' --class arch {
|
||||||
|
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
|
||||||
|
initrd /boot/initramfs-linux-fallback.img
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// kernelCopyingHook copies every installed kernel image to the default kernel's name on the boot partition.
|
||||||
|
const kernelCopyingHook = `[Trigger]
|
||||||
|
Operation = Install
|
||||||
|
Operation = Upgrade
|
||||||
|
Type = Path
|
||||||
|
Target = usr/lib/modules/*/vmlinuz
|
||||||
|
|
||||||
|
[Action]
|
||||||
|
Description = Copying the kernel to the default name
|
||||||
|
When = PostTransaction
|
||||||
|
Exec = /usr/bin/sh -c '/usr/bin/cp /usr/lib/modules/$(uname -r)/vmlinuz /boot/vmlinuz-linux'
|
||||||
|
`
|
||||||
|
|
||||||
|
// otherHook is a hook of the distribution's shape that has nothing to do with kernel images.
|
||||||
|
const otherHook = `[Trigger]
|
||||||
|
Operation = Upgrade
|
||||||
|
Type = Package
|
||||||
|
Target = pacman-mirrorlist
|
||||||
|
|
||||||
|
[Action]
|
||||||
|
Description = Updating the mirror list
|
||||||
|
When = PostTransaction
|
||||||
|
Exec = /usr/bin/cp /etc/pacman.d/mirrorlist.pacnew /etc/pacman.d/mirrorlist
|
||||||
|
`
|
||||||
|
|
||||||
|
type bootBed struct {
|
||||||
|
script, root, state, bin, mkconfig string
|
||||||
|
need string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBootBed(t *testing.T) bootBed {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := bootBed{script: filepath.Join(dir, "boot-entries"), root: filepath.Join(dir, "root"), state: filepath.Join(dir, "state"),
|
||||||
|
bin: filepath.Join(dir, "bin"), mkconfig: filepath.Join(dir, "mkconfig")}
|
||||||
|
content := manifest(t).resource(t, "boot-entries").text(t)
|
||||||
|
content = strings.ReplaceAll(content, "${dir:state}", b.state)
|
||||||
|
if strings.Contains(content, "${") {
|
||||||
|
t.Fatalf("a substitution the test does not make is left in the script")
|
||||||
|
}
|
||||||
|
for _, d := range []string{b.root + "/boot", b.root + "/proc", b.root + "/etc/pacman.d/hooks", b.state, b.bin} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write(t, b.script, content, 0o755)
|
||||||
|
write(t, b.root+"/proc/cmdline", bootedCmdline, 0o644)
|
||||||
|
// the stand-in grub-mkconfig records the kernel it was told to put first, then its arguments.
|
||||||
|
write(t, filepath.Join(b.bin, "grub-mkconfig"), "#!/bin/sh\necho \"top=$GRUB_TOP_LEVEL $@\" >>"+b.mkconfig+"\n", 0o755)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b bootBed) put(t *testing.T, p, content string) {
|
||||||
|
t.Helper()
|
||||||
|
full := filepath.Join(b.root, p)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
write(t, full, content, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b bootBed) systemdBoot(t *testing.T) {
|
||||||
|
b.put(t, "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f", "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
|
||||||
|
b.put(t, "boot/loader/loader.conf", loaderConf)
|
||||||
|
b.put(t, "boot/loader/entries/arch.conf", defaultEntry)
|
||||||
|
b.put(t, "boot/vmlinuz-linux", "k")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b bootBed) run(t *testing.T) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
args := []string{b.script, "run", b.root, b.state}
|
||||||
|
if b.need != "" {
|
||||||
|
args = append(args, b.need)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("sh", args...)
|
||||||
|
cmd.Env = append(os.Environ(), "PATH="+b.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b bootBed) read(t *testing.T, p string) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, _ := os.ReadFile(filepath.Join(b.root, p))
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASystemdBootMachineGetsTheFallbackEntryFromItsBootedCommandLine(t *testing.T) {
|
||||||
|
b := newBootBed(t)
|
||||||
|
b.systemdBoot(t)
|
||||||
|
b.put(t, "boot/intel-ucode.img", "u")
|
||||||
|
out, err := b.run(t)
|
||||||
|
if err != nil || !strings.Contains(out, "wrote") {
|
||||||
|
t.Fatalf("%v %s", err, out)
|
||||||
|
}
|
||||||
|
want := "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\ninitrd /intel-ucode.img\ninitrd /initramfs-linux-lts.img\n" +
|
||||||
|
"options root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3\n"
|
||||||
|
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); got != want {
|
||||||
|
t.Fatalf("entry:\n%s\nwant:\n%s", got, want)
|
||||||
|
}
|
||||||
|
// The default entry and the loader's own file are as they were.
|
||||||
|
if b.read(t, "boot/loader/entries/arch.conf") != defaultEntry || b.read(t, "boot/loader/loader.conf") != loaderConf {
|
||||||
|
t.Fatalf("the default entry or loader.conf was touched")
|
||||||
|
}
|
||||||
|
if out, err := b.run(t); err != nil || !strings.Contains(out, "as it was") {
|
||||||
|
t.Fatalf("again: %v %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithoutMicrocodeThereIsNoMicrocodeLine(t *testing.T) {
|
||||||
|
b := newBootBed(t)
|
||||||
|
b.systemdBoot(t)
|
||||||
|
if out, err := b.run(t); err != nil {
|
||||||
|
t.Fatalf("%v %s", err, out)
|
||||||
|
}
|
||||||
|
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); strings.Contains(got, "ucode") || !strings.Contains(got, "initrd /initramfs-linux-lts.img") {
|
||||||
|
t.Fatalf("entry: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAGrubMachineIsGeneratedAgainOnlyWhenItsKernelsChanged(t *testing.T) {
|
||||||
|
b := newBootBed(t)
|
||||||
|
b.put(t, "boot/grub/grub.cfg", grubMenu)
|
||||||
|
b.put(t, "boot/vmlinuz-linux", "k")
|
||||||
|
if out, err := b.run(t); err != nil || !strings.Contains(out, "left as it is") {
|
||||||
|
t.Fatalf("same: %v %s", err, out)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(b.mkconfig); err == nil {
|
||||||
|
t.Fatalf("the menu was generated again with the same kernels")
|
||||||
|
}
|
||||||
|
b.put(t, "boot/vmlinuz-linux-lts", "k")
|
||||||
|
if out, err := b.run(t); err != nil || !strings.Contains(out, "generated again") {
|
||||||
|
t.Fatalf("changed: %v %s", err, out)
|
||||||
|
}
|
||||||
|
raw, _ := os.ReadFile(b.mkconfig)
|
||||||
|
if string(raw) != "top=/boot/vmlinuz-linux -o "+filepath.Join(b.root, "boot/grub/grub.cfg")+"\n" {
|
||||||
|
t.Fatalf("grub-mkconfig was asked %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0508 §9: GRUB's menu lists kernels in reverse version order, so linux-lts would come first and become the
|
||||||
|
// default entry (GRUB_DEFAULT=0); the kernel the machine booted is put first, and the fallback kernel never.
|
||||||
|
func TestGrubKeepsTheBootedKernelFirstAndNeverTheFallback(t *testing.T) {
|
||||||
|
for name, c := range map[string]struct{ cmdline, want string }{
|
||||||
|
"booted from the default kernel": {"BOOT_IMAGE=/vmlinuz-linux root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
||||||
|
"booted from a grub device path": {"BOOT_IMAGE=(hd0,msdos1)/vmlinuz-linux-zen root=UUID=x rw\n", "top=/boot/vmlinuz-linux-zen"},
|
||||||
|
"booted from the fallback kernel": {"BOOT_IMAGE=/vmlinuz-linux-lts root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
||||||
|
"no image on the command line": {"root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
||||||
|
"an image no longer in /boot": {"BOOT_IMAGE=/vmlinuz-linux-gone root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
||||||
|
} {
|
||||||
|
b := newBootBed(t)
|
||||||
|
b.put(t, "boot/grub/grub.cfg", grubMenu)
|
||||||
|
b.put(t, "proc/cmdline", c.cmdline)
|
||||||
|
for _, k := range []string{"vmlinuz-linux", "vmlinuz-linux-lts", "vmlinuz-linux-zen"} {
|
||||||
|
b.put(t, "boot/"+k, "k")
|
||||||
|
}
|
||||||
|
if out, err := b.run(t); err != nil {
|
||||||
|
t.Fatalf("%s: %v %s", name, err, out)
|
||||||
|
}
|
||||||
|
raw, _ := os.ReadFile(b.mkconfig)
|
||||||
|
if !strings.HasPrefix(string(raw), c.want+" -o ") {
|
||||||
|
t.Errorf("%s: grub-mkconfig was asked %q, want %s first", name, raw, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMachineWithNoLoaderFailsLoudly(t *testing.T) {
|
||||||
|
b := newBootBed(t)
|
||||||
|
out, err := b.run(t)
|
||||||
|
if err == nil || !strings.Contains(out, "no boot loader found") {
|
||||||
|
t.Fatalf("%v %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABootPartitionWithoutRoomFailsBeforeAnythingIsWritten(t *testing.T) {
|
||||||
|
b := newBootBed(t)
|
||||||
|
b.systemdBoot(t)
|
||||||
|
b.need = "999999999999"
|
||||||
|
out, err := b.run(t)
|
||||||
|
if err == nil || !strings.Contains(out, "free room there before the kernel is installed") {
|
||||||
|
t.Fatalf("%v %s", err, out)
|
||||||
|
}
|
||||||
|
if b.read(t, "boot/loader/entries/mesh-linux-lts.conf") != "" {
|
||||||
|
t.Fatalf("an entry was written")
|
||||||
|
}
|
||||||
|
// Once the fallback kernel is in place the room is not asked for again.
|
||||||
|
b.put(t, "boot/vmlinuz-linux-lts", "k")
|
||||||
|
if out, err := b.run(t); err != nil {
|
||||||
|
t.Fatalf("%v %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHookThatCopiesKernelsToOneFixedNameIsMovedIntoTheStateAndNamed(t *testing.T) {
|
||||||
|
b := newBootBed(t)
|
||||||
|
b.systemdBoot(t)
|
||||||
|
b.put(t, "etc/pacman.d/hooks/60-copy-kernel.hook", kernelCopyingHook)
|
||||||
|
b.put(t, "etc/pacman.d/hooks/70-mirrorlist.hook", otherHook)
|
||||||
|
b.put(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", "[Trigger]\nTarget = usr/lib/modules/*/vmlinuz\n[Action]\nExec = /bin/cp x /boot/y\n")
|
||||||
|
out, err := b.run(t)
|
||||||
|
if err != nil || !strings.Contains(out, "taken over: the pacman hook 60-copy-kernel.hook") {
|
||||||
|
t.Fatalf("%v %s", err, out)
|
||||||
|
}
|
||||||
|
if b.read(t, "etc/pacman.d/hooks/60-copy-kernel.hook") != "" {
|
||||||
|
t.Fatalf("the copying hook is still in the hook directory")
|
||||||
|
}
|
||||||
|
if raw, _ := os.ReadFile(filepath.Join(b.state, "taken-over", "60-copy-kernel.hook")); string(raw) != kernelCopyingHook {
|
||||||
|
t.Fatalf("moved hook: %q", raw)
|
||||||
|
}
|
||||||
|
if b.read(t, "etc/pacman.d/hooks/70-mirrorlist.hook") != otherHook || b.read(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook") == "" {
|
||||||
|
t.Fatalf("a hook that copies no kernel was moved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheBootEntriesUnitAndHookAreTheModules(t *testing.T) {
|
||||||
|
m := manifest(t)
|
||||||
|
unit := m.resource(t, "boot-unit").text(t)
|
||||||
|
for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/boot-entries run", "ProtectSystem=strict",
|
||||||
|
"ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks", "NoNewPrivileges=yes", "WantedBy=multi-user.target"} {
|
||||||
|
if !strings.Contains(unit, line+"\n") {
|
||||||
|
t.Errorf("the unit lacks %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hook := m.resource(t, "boot-hook")
|
||||||
|
text := hook.text(t)
|
||||||
|
if hook["path"] != "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook" || !strings.Contains(text, "Target = usr/lib/modules/*/vmlinuz\n") ||
|
||||||
|
!strings.Contains(text, "When = PostTransaction\n") || !strings.Contains(text, "Exec = ${dir:state}/boot-entries run\n") {
|
||||||
|
t.Fatalf("the hook: %v\n%s", hook["path"], text)
|
||||||
|
}
|
||||||
|
apply := m.resource(t, "boot-apply")
|
||||||
|
if apply["unit"] != "mesh-kernel-boot-entries.service" || apply["state"] != "running" || apply["boot"] != "enabled" {
|
||||||
|
t.Fatalf("apply: %v", apply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fallback kernel and its headers are declared after the boot-entries service, so the entry is placed
|
||||||
|
// before the kernel is installed and every DKMS module is built for it (ADR 0508 §9).
|
||||||
|
func TestTheFallbackKernelAndItsHeadersAreDeclaredAfterTheBootEntriesUnit(t *testing.T) {
|
||||||
|
m := manifest(t)
|
||||||
|
at := map[string]int{}
|
||||||
|
for i, r := range m.Resources {
|
||||||
|
at[r["id"].(string)] = i
|
||||||
|
}
|
||||||
|
for id, pkg := range map[string]string{"linux-lts": "linux-lts", "linux-lts-headers": "linux-lts-headers"} {
|
||||||
|
r := m.resource(t, id)
|
||||||
|
if r["type"] != "package" || r["package"] != pkg || r["absent"] == true {
|
||||||
|
t.Errorf("%s: %v", id, r)
|
||||||
|
}
|
||||||
|
if at[id] < at["boot-apply"] || at[id] < at["boot-hook"] {
|
||||||
|
t.Errorf("%s is declared before the boot-entries unit and hook", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// Over a stand-in tree holding novox's own files of 2026-10-14 23:25 (read through node-reads for
|
// Over a stand-in tree holding the files of a real machine of 2026-10-14 23:25 (read through node-reads for
|
||||||
// novox/hq issue 746): the mode, the per-size controls, meminfo's huge page lines, and fail2ban's and
|
// novox/hq issue 746): the mode, the per-size controls, meminfo's huge page lines, and fail2ban's and
|
||||||
// journald's memory.stat and io.stat, cut to the lines the tool reads.
|
// journald's memory.stat and io.stat, cut to the lines the tool reads.
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ func tree(t *testing.T, files map[string]string) *Machine {
|
|||||||
return &Machine{Root: root}
|
return &Machine{Root: root}
|
||||||
}
|
}
|
||||||
|
|
||||||
func novox(t *testing.T) *Machine {
|
func capturedMachine(t *testing.T) *Machine {
|
||||||
thp := "sys/kernel/mm/transparent_hugepage/"
|
thp := "sys/kernel/mm/transparent_hugepage/"
|
||||||
cg := "sys/fs/cgroup/system.slice/"
|
cg := "sys/fs/cgroup/system.slice/"
|
||||||
files := map[string]string{
|
files := map[string]string{
|
||||||
@@ -53,8 +53,8 @@ func novox(t *testing.T) *Machine {
|
|||||||
return tree(t, files)
|
return tree(t, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNovoxBeforeTheChange(t *testing.T) {
|
func TestTheCapturedMachineBeforeTheChange(t *testing.T) {
|
||||||
a, err := novox(t).HugePages(10)
|
a, err := capturedMachine(t).HugePages(10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func TestNovoxBeforeTheChange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUnitsAreBoundedAndNoneIsSaid(t *testing.T) {
|
func TestUnitsAreBoundedAndNoneIsSaid(t *testing.T) {
|
||||||
a, err := novox(t).HugePages(1)
|
a, err := capturedMachine(t).HugePages(1)
|
||||||
if err != nil || len(a.Units) != 1 {
|
if err != nil || len(a.Units) != 1 {
|
||||||
t.Fatalf("%v %+v", err, a)
|
t.Fatalf("%v %+v", err, a)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,15 @@ func tools(m *Machine) []stdio.Tool {
|
|||||||
return m.HugePages(n)
|
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 {
|
for _, r := range m.Resources {
|
||||||
ids = append(ids, r["id"].(string))
|
ids = append(ids, r["id"].(string))
|
||||||
}
|
}
|
||||||
if strings.Join(ids, " ") != "state huge-pages unit apply" {
|
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" {
|
||||||
t.Fatalf("order: %v", ids)
|
t.Fatalf("order: %v", ids)
|
||||||
}
|
}
|
||||||
script := m.resource(t, "huge-pages")
|
script := m.resource(t, "huge-pages")
|
||||||
@@ -129,7 +129,7 @@ func (b bed) file(t *testing.T, p string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApplySetsTheModeRotatesTheJournalOnceAndRestoreGivesBackWhatWasFound(t *testing.T) {
|
func TestApplySetsTheModeRotatesTheJournalOnceAndRestoreGivesBackWhatWasFound(t *testing.T) {
|
||||||
// novox's file on 2026-10-14, read through node-reads.
|
// a real machine's file on 2026-10-14, read through node-reads.
|
||||||
b := newBed(t, "madvise", "[always] madvise never\n")
|
b := newBed(t, "madvise", "[always] madvise never\n")
|
||||||
out, err := b.run(t, "apply")
|
out, err := b.run(t, "apply")
|
||||||
if err != nil || !strings.Contains(out, "always -> madvise") {
|
if err != nil || !strings.Contains(out, "always -> madvise") {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Module kernel, novox/hq ADR 0508 §9, issue 1841. After a kernel image is installed, upgraded or removed, the
|
||||||
|
# fallback kernel's boot entry (or the GRUB menu) is placed again. Written by the mesh; change the module.
|
||||||
|
[Trigger]
|
||||||
|
Operation = Install
|
||||||
|
Operation = Upgrade
|
||||||
|
Operation = Remove
|
||||||
|
Type = Path
|
||||||
|
Target = usr/lib/modules/*/vmlinuz
|
||||||
|
|
||||||
|
[Action]
|
||||||
|
Description = Placing the fallback kernel on the boot menu
|
||||||
|
When = PostTransaction
|
||||||
|
Exec = ${dir:state}/boot-entries run
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# The fallback kernel's place on this machine's boot menu (module kernel, novox/hq ADR 0508 §9 and §10,
|
||||||
|
# issue 1841). Written by the mesh and run as root by mesh-kernel-boot-entries.service at boot and when the
|
||||||
|
# module is applied, and by the pacman hook 90-mesh-kernel-boot-entries.hook after a kernel is installed,
|
||||||
|
# upgraded or removed. Replaced on every push; change the module, never this file.
|
||||||
|
#
|
||||||
|
# boot-entries run [<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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=The fallback kernel's place on this machine's boot menu
|
||||||
|
# Module kernel, novox/hq ADR 0508 §9, issue 1841. Starting this unit reads which loader booted the machine and
|
||||||
|
# places the fallback kernel's boot entry (systemd-boot) or generates the menu again (GRUB); it fails, and says
|
||||||
|
# why, when there is no loader or the boot partition has no room. The kernel packages are declared after this
|
||||||
|
# unit, so the entry is in place before they are installed.
|
||||||
|
After=local-fs.target
|
||||||
|
Wants=local-fs.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=${dir:state}/boot-entries run
|
||||||
|
# It writes the boot partition, the pacman hook directory and its own state, and nothing else.
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateNetwork=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": [
|
"tools": [
|
||||||
"kernel_huge_pages"
|
"kernel_huge_pages",
|
||||||
|
"kernel_boot"
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
@@ -53,6 +54,52 @@
|
|||||||
"health": {
|
"health": {
|
||||||
"kind": "unit"
|
"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": {
|
"build": {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ type guardWorld struct {
|
|||||||
|
|
||||||
func newGuardWorld(t *testing.T) *guardWorld {
|
func newGuardWorld(t *testing.T) *guardWorld {
|
||||||
w := &guardWorld{now: time.Date(2026, 10, 5, 23, 55, 0, 0, time.UTC)}
|
w := &guardWorld{now: time.Date(2026, 10, 5, 23, 55, 0, 0, time.UTC)}
|
||||||
w.f = newFakeKeycloak(t, "Novox", "old-password-from-2022")
|
w.f = newFakeKeycloak(t, "NodeC", "old-password-from-2022")
|
||||||
w.x = &fakeExec{f: w.f}
|
w.x = &fakeExec{f: w.f}
|
||||||
w.g = &Guard{KC: w.f.client("the-mesh-minted-this"), Exec: w.x,
|
w.g = &Guard{KC: w.f.client("the-mesh-minted-this"), Exec: w.x,
|
||||||
Now: func() time.Time { return w.now },
|
Now: func() time.Time { return w.now },
|
||||||
@@ -195,7 +195,7 @@ func TestWhileTheAdminIsRefusedTheProvisionerDoesNotAskKeycloak(t *testing.T) {
|
|||||||
w := newGuardWorld(t)
|
w := newGuardWorld(t)
|
||||||
w.x.fails = true
|
w.x.fails = true
|
||||||
w.g.Ensure(ctx, true, false)
|
w.g.Ensure(ctx, true, false)
|
||||||
a := provisioner{clients: OidcClients{KC: w.g.KC, Realm: "Novox"}, guard: w.g,
|
a := provisioner{clients: OidcClients{KC: w.g.KC, Realm: "NodeC"}, guard: w.g,
|
||||||
announce: func(string, map[string]any) {}, log: func(string, ...any) {}}
|
announce: func(string, map[string]any) {}, log: func(string, ...any) {}}
|
||||||
logins := w.f.logins
|
logins := w.f.logins
|
||||||
err := a.Create(ctx, grafana("s", nil))
|
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 novox.mesh-mail-web, not moved) is wanted and
|
// 1332/AC1: a consumer only the grant file gives (oidc-client for node-c.mesh-mail-web, not moved) is wanted and
|
||||||
// Adapter.Create is asked for its client, though keycloak runs on the moved path.
|
// Adapter.Create is asked for its client, though keycloak runs on the moved path.
|
||||||
func TestAGrantFileConsumerNotMovedIsMadeOnTheMovedPath(t *testing.T) {
|
func TestAGrantFileConsumerNotMovedIsMadeOnTheMovedPath(t *testing.T) {
|
||||||
mailWeb := "mesh_novox_mail_web"
|
mailWeb := "mesh_node_c_mail_web"
|
||||||
receives := grantGives(t, map[string]any{"as": mailWeb, "node": "novox", "values": map[string]any{
|
receives := grantGives(t, map[string]any{"as": mailWeb, "node": "node-c", "values": map[string]any{
|
||||||
"label": "mail-web", "endpoint": "web", "callback": "/login/generic_oauth", "name": "mail.example.org"}})
|
"label": "mail-web", "endpoint": "web", "callback": "/login/generic_oauth", "name": "mail.example.org"}})
|
||||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "novox"}
|
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "node-c"}
|
||||||
f, start := movedWorld(t, h)
|
f, start := movedWorld(t, h)
|
||||||
start()
|
start()
|
||||||
waitUntil(t, time.Second, func() bool {
|
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_ace_grafana) counts as asked and
|
// 1332/AC2: a grant-file consumer whose client already exists in the backend (mesh_node_a_grafana) counts as asked and
|
||||||
// is not held for retirement on the moved path.
|
// is not held for retirement on the moved path.
|
||||||
func TestAGrantFileConsumerAlreadyInKeycloakIsNotRetiredOnTheMovedPath(t *testing.T) {
|
func TestAGrantFileConsumerAlreadyInKeycloakIsNotRetiredOnTheMovedPath(t *testing.T) {
|
||||||
grafana := "mesh_ace_grafana"
|
grafana := "mesh_node_a_grafana"
|
||||||
receives := grantGives(t, map[string]any{"as": grafana, "node": "ace", "values": map[string]any{
|
receives := grantGives(t, map[string]any{"as": grafana, "node": "node-a", "values": map[string]any{
|
||||||
"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth", "name": "grafana.example.org"}})
|
"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth", "name": "grafana.example.org"}})
|
||||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "ace",
|
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "node-a",
|
||||||
StablePasses: 1, StableFor: time.Millisecond}
|
StablePasses: 1, StableFor: time.Millisecond}
|
||||||
f, start := movedWorld(t, h)
|
f, start := movedWorld(t, h)
|
||||||
// Seed the backend with the client the mesh made earlier, exactly as grafana's already is — before anything
|
// Seed the backend with the client the mesh made earlier, exactly as grafana's already is — before anything
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user