Compare commits
330
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79777d0f78 | ||
|
|
d2036f2a63 | ||
|
|
6c0024e43b | ||
|
|
a2e5a20f52 | ||
|
|
845479d7cd | ||
|
|
2ebcb18097 | ||
|
|
00e54d46d7 | ||
|
|
933370df7c | ||
|
|
437c524b81 | ||
|
|
dc96d7c2dd | ||
|
|
f9247342d1 | ||
|
|
b1d875410a | ||
|
|
89e15c7bcd | ||
|
|
a21da33d48 | ||
|
|
b53b0e7e2c | ||
|
|
48dd41db91 | ||
|
|
16331cdfdc | ||
|
|
ab3722d3b3 | ||
|
|
66c404fd39 | ||
|
|
3ad9d42be9 | ||
|
|
083515da1b | ||
|
|
ac5d257a1c | ||
|
|
896bd88380 | ||
|
|
24f50c7c86 | ||
|
|
e0bf87c2e3 | ||
|
|
ef1e98f867 | ||
|
|
e3db481491 | ||
|
|
21b4925892 | ||
|
|
098c6d3ec7 | ||
|
|
0fdfecfa29 | ||
|
|
97f0f4813c | ||
|
|
8d3f81c2c0 | ||
|
|
8f10d3192c | ||
|
|
1e3439429f | ||
|
|
e9e8c7c901 | ||
|
|
db35f2b0cb | ||
|
|
f90d3bfcc0 | ||
|
|
37c47f8f95 | ||
|
|
a45e467ddd | ||
|
|
d3f9a1855d | ||
|
|
c97cea21cf | ||
|
|
2f05e70734 | ||
|
|
5ab9d6c296 | ||
|
|
e61c5ee5bf | ||
|
|
b5f812270e | ||
|
|
0786408109 | ||
|
|
a18eeeef5a | ||
|
|
5b6dfc70f4 | ||
|
|
59c28971cb | ||
|
|
035c950ff2 | ||
|
|
5a79776625 | ||
|
|
b9cdca78b0 | ||
|
|
fde561075d | ||
|
|
3cce15fa81 | ||
|
|
889fa09ea4 | ||
|
|
3a25b6e1f8 | ||
|
|
22992da15d | ||
|
|
b6247f837d | ||
|
|
9c5b7e14e1 | ||
|
|
c0873728e2 | ||
|
|
86ba54b06e | ||
|
|
af91a7e660 | ||
|
|
d45767bb2f | ||
|
|
d108487396 | ||
|
|
027ea030fa | ||
|
|
6eb9eb53de | ||
|
|
eed6be0f4a | ||
|
|
51900367a0 | ||
|
|
77596f37a3 | ||
|
|
56f842c3af | ||
|
|
075b61901c | ||
|
|
0270027bf9 | ||
|
|
835bf22b7d | ||
|
|
e85b7da63b | ||
|
|
c112df0c1c | ||
|
|
8e791896f1 | ||
|
|
b07a101caf | ||
|
|
72effa1fb1 | ||
|
|
e53ac973e9 | ||
|
|
f1f18bf2bd | ||
|
|
c41c968086 | ||
|
|
939475cb63 | ||
|
|
5e24deb0f1 | ||
|
|
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 | ||
|
|
0cf7b2344f | ||
|
|
aa5d263c08 | ||
|
|
769a8d6f6a | ||
|
|
34b310b9f8 | ||
|
|
33d8b71cf7 | ||
|
|
aa0eaa6c52 | ||
|
|
d5804927b3 | ||
|
|
3793175894 | ||
|
|
b70f246622 | ||
|
|
cdbdb47474 | ||
|
|
689e61081d | ||
|
|
c83f48b821 | ||
|
|
db5e6c5b48 | ||
|
|
b9dcf19c3f | ||
|
|
e3a714d5f0 | ||
|
|
08cec8cb1c | ||
|
|
70500c21a8 | ||
|
|
c3564c1acc | ||
|
|
e4cbffbaa9 | ||
|
|
0f159a1662 | ||
|
|
f0beea153e | ||
|
|
7a7a403485 | ||
|
|
02a8837348 | ||
|
|
0fa427dad3 | ||
|
|
37628f4bce | ||
|
|
6cf51173d5 | ||
|
|
ad85a979e3 | ||
|
|
56c68be7fa | ||
|
|
aef2a1a91f | ||
|
|
b2f5ebb27d | ||
|
|
3259765d46 | ||
|
|
4b9464f3ce | ||
|
|
1f7dd702d2 | ||
|
|
dd31d991cd | ||
|
|
a2deae03b7 | ||
|
|
72ef20a4e7 | ||
|
|
778356f10c | ||
|
|
7c97ce0bf8 | ||
|
|
68e1ad80ac | ||
|
|
6e2762d943 | ||
|
|
dc2b0641bf | ||
|
|
b54640e386 | ||
|
|
6358041eca | ||
|
|
657b38ccd1 | ||
|
|
e959cf339b | ||
|
|
cb226c6fef | ||
|
|
278014fd79 | ||
|
|
caae838a2a | ||
|
|
6ee3507af0 | ||
|
|
06ca1b7fb9 | ||
|
|
90ecba4e50 | ||
|
|
5b3fd1008a | ||
|
|
671ab9a3ba | ||
|
|
0a7c58766c | ||
|
|
454c5662dd | ||
|
|
8fba5e831b | ||
|
|
4efac366ce | ||
|
|
38ac738d84 | ||
|
|
92c5e1d9ae | ||
|
|
a01e1ea7fb | ||
|
|
d74c1cb995 | ||
|
|
2d30dc2f1e | ||
|
|
f178aab641 | ||
|
|
b5dbfbb784 | ||
|
|
e9e85365b5 | ||
|
|
710cfbd622 | ||
|
|
940594b074 | ||
|
|
ff849d9f62 | ||
|
|
f0c8958dd2 | ||
|
|
c33b83dce7 | ||
|
|
36259f3406 | ||
|
|
e023f69989 | ||
|
|
324e331184 | ||
|
|
d8d153295a | ||
|
|
bda75011a1 | ||
|
|
9d1f152603 | ||
|
|
c4cd7a1b16 | ||
|
|
7753c27042 | ||
|
|
ef154705b9 | ||
|
|
d7e6074d97 | ||
|
|
800d6624d5 | ||
|
|
0f2cee1bea | ||
|
|
ceb42b0e92 | ||
|
|
e5cfcb316a | ||
|
|
025d62478a | ||
|
|
e6053262e4 | ||
|
|
6993ef003c | ||
|
|
406ee69732 | ||
|
|
8bfa626a8b | ||
|
|
bfd6b7c7ce | ||
|
|
640c6f9b0c | ||
|
|
d369adf245 | ||
|
|
d8f87808b5 |
@@ -27,3 +27,4 @@ modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-cli
|
||||
modules/brother/cmd/brother/brother
|
||||
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
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/locks
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,105 @@
|
||||
// Command locks refuses a TypeScript module that names packages beyond the SDK in its package.json and commits no
|
||||
// package-lock.json (novox/hq ADR 0469 §5, issue 1868): the Node build module installs a module's own packages only
|
||||
// from its committed lock, so such a module is refused by the builder and could not be delivered.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go
|
||||
// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Finding is one module refused: it names Packages beyond the SDK and has no lock file.
|
||||
type Finding struct {
|
||||
Module string
|
||||
Packages []string
|
||||
}
|
||||
|
||||
// Check reads every module under root/modules that builds a TypeScript artifact.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Language string `json:"language"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
typescript := false
|
||||
for _, a := range m.Build.Artifacts {
|
||||
typescript = typescript || a.Language == "typescript"
|
||||
}
|
||||
if !typescript {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
pkgRaw, err := os.ReadFile(filepath.Join(dir, "package.json"))
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pkg struct {
|
||||
Dependencies map[string]json.RawMessage `json:"dependencies"`
|
||||
}
|
||||
if err := json.Unmarshal(pkgRaw, &pkg); err != nil {
|
||||
return nil, fmt.Errorf("%s: package.json: %w", m.Module, err)
|
||||
}
|
||||
var names []string
|
||||
for n := range pkg.Dependencies {
|
||||
if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil {
|
||||
sort.Strings(names)
|
||||
out = append(out, Finding{Module: m.Module, Packages: names})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s names packages beyond the SDK (%s) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)\n", f.Module, strings.Join(f.Packages, ", "))
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("locks: every TypeScript module that names packages beyond the SDK commits its lock")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// module writes modules/<name> with a TypeScript artifact, the package.json given ("" for none) and, when lock, a lock.
|
||||
func module(t *testing.T, root, name, language, pkg string, lock bool) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(file, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("module.json", `{"module":"`+name+`","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"`+language+`","from":"index.ts"}]}}`)
|
||||
if pkg != "" {
|
||||
write("package.json", pkg)
|
||||
}
|
||||
if lock {
|
||||
write("package-lock.json", "{}")
|
||||
}
|
||||
}
|
||||
|
||||
const sdkOnly = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1"},"devDependencies":{"typescript":"^5","@types/node":"^22"}}`
|
||||
const withPg = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","pg":"^8","@types/pg":"^8"}}`
|
||||
|
||||
func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "plain", "typescript", sdkOnly, false)
|
||||
module(t, root, "none", "typescript", "", false)
|
||||
got, err := Check(root)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleNamingMorePackagesWithoutALockIsRefusedNamingThem(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "db", "typescript", withPg, false)
|
||||
module(t, root, "db-locked", "typescript", withPg, true)
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []Finding{{Module: "db", Packages: []string{"pg"}}}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "go", "go", withPg, false)
|
||||
if got, err := Check(root); err != nil || len(got) != 0 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
+181
-13
@@ -11,13 +11,23 @@
|
||||
// another module, after ${dir:<id>} is resolved within each module and both homes are read as one root.
|
||||
// Archives are not containers for this rule. This holds for ~/.ssh what checks/sshdir held (ADR 0415 §6).
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
// 5. a directory that is, lies inside or holds a directory a system package owns under /var/lib, or any other
|
||||
// resource inside one (issue 1841: pacman's state directory, placed with "place": ".", was /var/lib/pacman, the
|
||||
// package manager's own database directory). A placed directory is resolved as the mesh resolves it with the
|
||||
// default data root: "." is /var/lib/<module>, "mesh" /var/lib/mesh/<module>, neither /var/lib/<module>/<id>.
|
||||
// The system directories are checks/owned/system, captured from a machine (pacman -Qo, /usr/lib/tmpfiles.d);
|
||||
// checks/owned/system-known names the exceptions still to fix, by module, resource and issue. That list only
|
||||
// shrinks: an entry that no longer collides is refused, and with -base one the trunk's copy does not hold.
|
||||
//
|
||||
// go run . [-base <trunk's system-known>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
@@ -27,6 +37,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The lists of rule 5, relative to the catalogue's root.
|
||||
const (
|
||||
SystemList = "checks/owned/system"
|
||||
SystemKnown = "checks/owned/system-known"
|
||||
)
|
||||
|
||||
// Home is the one root both machine homes are read as.
|
||||
const Home = "${home}"
|
||||
|
||||
@@ -43,9 +59,9 @@ type Finding struct {
|
||||
}
|
||||
|
||||
type resource struct {
|
||||
ID, Type, Path, Unknown string
|
||||
Knows []string
|
||||
HasKnows bool
|
||||
ID, Type, Path, Unknown, Place string
|
||||
Knows []string
|
||||
HasKnows bool
|
||||
}
|
||||
|
||||
type parsed struct {
|
||||
@@ -65,6 +81,7 @@ func load(file string) (*parsed, error) {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Place string `json:"place"`
|
||||
Unknown *string `json:"unknown"`
|
||||
Knows []string `json:"knows"`
|
||||
} `json:"resources"`
|
||||
@@ -81,7 +98,7 @@ func load(file string) (*parsed, error) {
|
||||
out.Module = filepath.Base(filepath.Dir(file))
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Knows: r.Knows, HasKnows: r.Knows != nil}
|
||||
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Place: r.Place, Knows: r.Knows, HasKnows: r.Knows != nil}
|
||||
if r.Unknown != nil {
|
||||
x.Unknown = *r.Unknown
|
||||
if x.Unknown == "" {
|
||||
@@ -143,14 +160,23 @@ type declared struct {
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
func Check(root string, base []string) ([]Finding, error) {
|
||||
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
system, err := readSystem(filepath.Join(root, SystemList))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known, err := readLines(filepath.Join(root, SystemKnown))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
var out []Finding
|
||||
var paths []declared
|
||||
collided := map[string]bool{}
|
||||
type container struct{ module, path string }
|
||||
var containers []container
|
||||
for _, file := range files {
|
||||
@@ -170,6 +196,7 @@ func Check(root string) ([]Finding, error) {
|
||||
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
|
||||
}
|
||||
}
|
||||
out = append(out, inSystem(m, system, known, collided)...)
|
||||
for _, r := range m.Resources {
|
||||
p := norm(r.Path, dirs)
|
||||
if p != "" {
|
||||
@@ -212,6 +239,28 @@ func Check(root string) ([]Finding, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range known {
|
||||
if !collided[k] {
|
||||
module, id, _ := strings.Cut(k, " ")
|
||||
id, _, _ = strings.Cut(id, " ")
|
||||
out = append(out, Finding{5, module, id, fmt.Sprintf("is listed in %s, and no longer lies in a system "+
|
||||
"package's directory: take the line out, so the list only shrinks", SystemKnown)})
|
||||
}
|
||||
}
|
||||
if base != nil {
|
||||
held := map[string]bool{}
|
||||
for _, b := range base {
|
||||
held[b] = true
|
||||
}
|
||||
for _, k := range known {
|
||||
if !held[k] {
|
||||
module, id, _ := strings.Cut(k, " ")
|
||||
id, _, _ = strings.Cut(id, " ")
|
||||
out = append(out, Finding{5, module, id, fmt.Sprintf("is a new line in %s, which the trunk's copy "+
|
||||
"does not hold: a module's directory is placed outside a system package's, never listed", SystemKnown)})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range paths {
|
||||
for _, c := range containers {
|
||||
if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) {
|
||||
@@ -222,21 +271,140 @@ func Check(root string) ([]Finding, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
// systemDir is one directory a system package owns, and what says so.
|
||||
type systemDir struct{ Path, Source string }
|
||||
|
||||
// readLines is a list's lines, without comments and blank lines.
|
||||
func readLines(file string) ([]string, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found, err := Check(root)
|
||||
var lines []string
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, "#") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// readSystem is the list of system directories: "<absolute path> <package>: <how it was read>" per line. Absent or
|
||||
// empty, the check fails: a list that says nothing would let every module through.
|
||||
func readSystem(file string) ([]systemDir, error) {
|
||||
lines, err := readLines(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule 5 reads the system packages' directories from %s: %w", file, err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("%s names no system package's directory, so rule 5 would pass everything", file)
|
||||
}
|
||||
var out []systemDir
|
||||
for _, line := range lines {
|
||||
p, source, _ := strings.Cut(line, " ")
|
||||
if !strings.HasPrefix(p, "/var/lib/") || strings.TrimSpace(source) == "" {
|
||||
return nil, fmt.Errorf("%s: %q is not \"/var/lib/<directory> <package>: <how it was read>\"", file, line)
|
||||
}
|
||||
out = append(out, systemDir{path.Clean(p), strings.TrimSpace(source)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// placedAt is where the mesh places a pathless directory with the default data root (mesh-sdk catalogue dirsFor).
|
||||
func placedAt(module string, r resource) string {
|
||||
switch r.Place {
|
||||
case ".":
|
||||
return "/var/lib/" + module
|
||||
case "mesh":
|
||||
return "/var/lib/mesh/" + module
|
||||
}
|
||||
return "/var/lib/" + module + "/" + r.ID
|
||||
}
|
||||
|
||||
// inSystem is rule 5 for one module: each resource whose resolved path collides with a system package's directory,
|
||||
// unless system-known lists it (then it is marked collided, so a stale line is found).
|
||||
func inSystem(m *parsed, system []systemDir, known []string, collided map[string]bool) []Finding {
|
||||
real := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if r.Type == "directory" && r.Path == "" && r.ID != "" {
|
||||
real[r.ID] = placedAt(m.Module, r)
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r.ID != "" && r.Path != "" {
|
||||
real[r.ID] = r.Path
|
||||
}
|
||||
}
|
||||
var out []Finding
|
||||
for _, r := range m.Resources {
|
||||
p := ""
|
||||
if r.Path != "" {
|
||||
p = norm(r.Path, real)
|
||||
} else if r.Type == "directory" {
|
||||
p = real[r.ID]
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
continue
|
||||
}
|
||||
for _, s := range system {
|
||||
inside := p == s.Path || strings.HasPrefix(p, s.Path+"/")
|
||||
holds := r.Type == "directory" && strings.HasPrefix(s.Path, p+"/")
|
||||
if !inside && !holds {
|
||||
continue
|
||||
}
|
||||
listed := ""
|
||||
for _, k := range known {
|
||||
if strings.HasPrefix(k, m.Module+" "+r.ID+" ") || k == m.Module+" "+r.ID {
|
||||
listed = k
|
||||
}
|
||||
}
|
||||
if listed != "" {
|
||||
collided[listed] = true
|
||||
break
|
||||
}
|
||||
how := "is or lies inside"
|
||||
if holds {
|
||||
how = "holds"
|
||||
}
|
||||
out = append(out, Finding{5, m.Module, r.ID, fmt.Sprintf("resolves to %q, which %s %s, the directory of the "+
|
||||
"system package %s; place it under /var/lib/mesh/%s (\"place\": \"mesh\") or a path of its own", p, how,
|
||||
s.Path, s.Source, m.Module)})
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
baseFile := flag.String("base", "", "the trunk's copy of "+SystemKnown+": a line it does not hold is refused")
|
||||
flag.Parse()
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
}
|
||||
var base []string
|
||||
if *baseFile != "" {
|
||||
lines, err := readLines(*baseFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
base = append([]string{}, lines...)
|
||||
}
|
||||
found, err := Check(root, base)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8, issue 1024)\n", f.Module, f.ID, f.Why, f.Rule)
|
||||
cite := "novox/hq ADR 0422 §8, issue 1024"
|
||||
if f.Rule == 5 {
|
||||
cite = "issue 1841"
|
||||
}
|
||||
fmt.Printf("%s: %s: %s (rule %d, %s)\n", f.Module, f.ID, f.Why, f.Rule, cite)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory")
|
||||
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory, or in a system package's")
|
||||
}
|
||||
|
||||
+102
-1
@@ -20,7 +20,10 @@ func manifest(t *testing.T, root, module, body string) {
|
||||
|
||||
func check(t *testing.T, root string) []Finding {
|
||||
t.Helper()
|
||||
found, err := Check(root)
|
||||
if _, err := os.Stat(filepath.Join(root, SystemList)); os.IsNotExist(err) {
|
||||
lists(t, root, "/var/lib/pacman pacman: pacman -Qo\n", "")
|
||||
}
|
||||
found, err := Check(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -35,6 +38,20 @@ func only(t *testing.T, found []Finding, rule int, module, id string) {
|
||||
}
|
||||
}
|
||||
|
||||
// lists writes the catalogue's list of system directories and its known exceptions.
|
||||
func lists(t *testing.T, root, system, known string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, SystemList), []byte(system), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, SystemKnown), []byte(known), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func dir(id, path, rest string) string {
|
||||
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
|
||||
}
|
||||
@@ -145,3 +162,87 @@ func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC18: a module's directory that resolves to a /var/lib directory a system package owns, lies inside one or
|
||||
// holds one, is refused, whether placed (place ".", or by default beneath the module's own) or stated; a placed
|
||||
// directory saying place "mesh" lies under /var/lib/mesh and passes (issue 1841: pacman's state was /var/lib/pacman).
|
||||
func TestADirectoryOfASystemPackageIsRefused(t *testing.T) {
|
||||
system := "/var/lib/pacman pacman: pacman -Qo\n/var/lib/systemd/coredump systemd: tmpfiles.d/systemd.conf\n"
|
||||
for name, resources := range map[string][]string{
|
||||
"placed-own": {`{"id":"state","type":"directory","place":".","unknown":"program"}`},
|
||||
"placed-beneath": {`{"id":"coredump","type":"directory","unknown":"program"}`},
|
||||
"stated-inside": {dir("d", "/var/lib/pacman/keeper", `,"unknown":"program"`)},
|
||||
"stated-holding": {dir("d", "/var/lib/systemd", `,"unknown":"program"`)},
|
||||
"file-inside": {`{"id":"state","type":"directory","place":"mesh","unknown":"program"}`,
|
||||
`{"id":"f","type":"file","path":"/var/lib/pacman/keeper.json"}`},
|
||||
} {
|
||||
module := map[string]string{"placed-own": "pacman", "placed-beneath": "systemd"}[name]
|
||||
if module == "" {
|
||||
module = "keeper"
|
||||
}
|
||||
root := t.TempDir()
|
||||
lists(t, root, system, "")
|
||||
manifest(t, root, module, mod(module, resources...))
|
||||
found := check(t, root)
|
||||
if len(found) != 1 || found[0].Rule != 5 || found[0].Module != module ||
|
||||
!strings.Contains(found[0].Why, "/var/lib/") || !strings.Contains(found[0].Why, "package") {
|
||||
t.Errorf("%s: found %+v; want one finding of rule 5 for %s naming the path and the package", name, found, module)
|
||||
}
|
||||
}
|
||||
root := t.TempDir()
|
||||
lists(t, root, system, "")
|
||||
manifest(t, root, "pacman", mod("pacman",
|
||||
`{"id":"state","type":"directory","place":"mesh","unknown":"program"}`,
|
||||
`{"id":"f","type":"file","path":"${dir:state}/keeper.json"}`,
|
||||
dir("other", "/var/lib/pacman-other", `,"unknown":"program"`)))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("a directory under /var/lib/mesh/pacman was refused: %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC18: a known exception passes; one that no longer collides is refused, so the list only shrinks; one the trunk's
|
||||
// copy does not hold is refused; and an absent list of system directories fails the check rather than passing it.
|
||||
func TestKnownSystemExceptionsOnlyShrink(t *testing.T) {
|
||||
system := "/var/lib/systemd systemd: tmpfiles.d/systemd.conf\n"
|
||||
known := "# comment\nsystemd state issue 1873\n"
|
||||
root := t.TempDir()
|
||||
lists(t, root, system, known)
|
||||
manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":".","unknown":"program"}`))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("a known exception was refused: %+v", found)
|
||||
}
|
||||
if found, err := Check(root, []string{"systemd state issue 1873"}); err != nil || len(found) != 0 {
|
||||
t.Fatalf("a known exception held by the trunk was refused: %+v %v", found, err)
|
||||
}
|
||||
if found, err := Check(root, []string{}); err != nil || len(found) != 1 || found[0].Rule != 5 {
|
||||
t.Fatalf("an exception the trunk does not hold passed: %+v %v", found, err)
|
||||
}
|
||||
|
||||
manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":"mesh","unknown":"program"}`))
|
||||
only(t, check(t, root), 5, "systemd", "state")
|
||||
|
||||
root = t.TempDir()
|
||||
manifest(t, root, "a", mod("a"))
|
||||
if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, SystemList), []byte("# nothing\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Check(root, nil); err == nil {
|
||||
t.Fatal("an empty list of system directories passed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC18: the catalogue as it stands passes rule 5 with its own lists.
|
||||
func TestTheCatalogueKeepsOutOfSystemDirectories(t *testing.T) {
|
||||
found, err := Check("../..", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
if f.Rule == 5 {
|
||||
t.Errorf("%s: %s: %s", f.Module, f.ID, f.Why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# The directories under /var/lib that a system package owns (rule 5 of checks/owned, issue 1841). A module's
|
||||
# directory, placed or stated, may not be one of these, lie inside one or hold one: the package's program and the
|
||||
# package manager keep their own files there, and the node-engine would judge, chmod or chown the directory.
|
||||
#
|
||||
# One line per directory: "<path> <package>: <how it was read>". Captured on the workstation on 2026-10-22, through
|
||||
# the mesh: pacman's own database by `pacman -Qo` (pacman_owns), the rest from the `d`, `D` and `Q` lines of
|
||||
# /usr/lib/tmpfiles.d (node-reads.search for "/var/lib/"). A directory found to belong to a package is added here.
|
||||
/var/lib/pacman pacman: pacman -Qo /var/lib/pacman/ (pacman 7.1.0)
|
||||
/var/lib/alsa alsa-utils: tmpfiles.d/alsa-utils.conf
|
||||
/var/lib/colord colord: tmpfiles.d/colord.conf
|
||||
/var/lib/containers podman: tmpfiles.d/podman.conf
|
||||
/var/lib/dbus dbus: tmpfiles.d/dbus.conf
|
||||
/var/lib/geoclue geoclue: tmpfiles.d/geoclue.conf
|
||||
/var/lib/lastlog util-linux: tmpfiles.d/lastlog2.conf
|
||||
/var/lib/libuuid util-linux: tmpfiles.d/uuidd.conf
|
||||
/var/lib/machines systemd: tmpfiles.d/systemd-nspawn.conf
|
||||
/var/lib/openvpn/chroot networkmanager-openvpn: tmpfiles.d/nm-openvpn-tmpfiles.conf
|
||||
/var/lib/portables systemd: tmpfiles.d/portables.conf
|
||||
/var/lib/postgres postgresql: tmpfiles.d/postgresql.conf
|
||||
/var/lib/private systemd: tmpfiles.d/systemd.conf
|
||||
/var/lib/rpcbind rpcbind: tmpfiles.d/rpcbind.conf
|
||||
/var/lib/systemd systemd: tmpfiles.d/systemd.conf
|
||||
/var/lib/tpm2-tss/system/keystore tpm2-tss: tmpfiles.d/tpm2-tss-fapi.conf
|
||||
@@ -0,0 +1,5 @@
|
||||
# Modules whose directory lies in a system package's directory today (rule 5 of checks/owned, issue 1841), each with
|
||||
# the issue that moves it out: "<module> <resource id> issue <number>". This list only shrinks: a line whose module
|
||||
# no longer collides is refused, and so is a line the trunk's copy does not hold.
|
||||
postgres state issue 1873
|
||||
systemd state issue 1873
|
||||
@@ -0,0 +1,3 @@
|
||||
# The modules the check covers (novox/hq ADR 0507 §9, issue 1838), one a line; issue 1838 widens it.
|
||||
pacman
|
||||
kernel
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/particulars
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,159 @@
|
||||
// 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 a covered module names a node of this one.
|
||||
//
|
||||
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
|
||||
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
|
||||
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
|
||||
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
|
||||
// before a line is read.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// 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"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// forge is the forge's host and organisation path, blanked before a line is read; a path segment novox/ elsewhere
|
||||
// (testdata/novox/x, /srv/novox/data) is not blanked.
|
||||
var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w.-])novox/[\w.-]+`)
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
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 {
|
||||
names, err := readList(filepath.Join(root, "particulars"))
|
||||
if err == nil && len(names) == 0 {
|
||||
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
|
||||
}
|
||||
var covered []string
|
||||
if err == nil {
|
||||
covered, err = readList(filepath.Join(root, "checks/particulars/covered"))
|
||||
if err == nil && len(covered) == 0 {
|
||||
err = fmt.Errorf("covered lists no module — the check would pass on anything")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||
return 2
|
||||
}
|
||||
findings, err := run(root, names, covered)
|
||||
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 covered modules\n", len(names), len(covered))
|
||||
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()
|
||||
}
|
||||
|
||||
// run reads every text file of each covered module and returns the findings, sorted.
|
||||
func run(root string, names, covered []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, m := range covered {
|
||||
dir := filepath.Join(root, "modules", m)
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
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(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), 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 blanked first.
|
||||
func hits(line string, names []string) []string {
|
||||
line = forge.ReplaceAllStringFunc(line, func(s string) string { return strings.Repeat(" ", len(s)) })
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
q := regexp.QuoteMeta(n)
|
||||
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
|
||||
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
|
||||
if rx.MatchString(line) {
|
||||
out = append(out, strings.ToLower(n))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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",
|
||||
"git.novox.be",
|
||||
"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"})
|
||||
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("checks/particulars/covered", "kernel\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 TestOnlyCoveredModulesAreRead(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"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || !strings.Contains(got[0], "modules/kernel/files/a:1") {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/toolchains
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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, 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,
|
||||
// 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.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Owners names, for each toolchain package, the one module that declares it.
|
||||
var Owners = map[string]string{
|
||||
"buildah": "buildah",
|
||||
"go": "go",
|
||||
"nodejs": "nodejs",
|
||||
"npm": "nodejs",
|
||||
}
|
||||
|
||||
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
|
||||
// the Package it owns.
|
||||
type Finding struct {
|
||||
Module, Package, Owner string
|
||||
Missing bool
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what 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
|
||||
declared := map[string]map[string]bool{} // module -> packages
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []struct {
|
||||
Type string `json:"type"`
|
||||
Package string `json:"package"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
declared[m.Module] = map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if r.Type != "package" {
|
||||
continue
|
||||
}
|
||||
declared[m.Module][r.Package] = true
|
||||
if owner, ok := Owners[r.Package]; ok && owner != m.Module {
|
||||
out = append(out, Finding{Module: m.Module, Package: r.Package, Owner: owner})
|
||||
}
|
||||
}
|
||||
}
|
||||
var pkgs []string
|
||||
for p := range Owners {
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
sort.Strings(pkgs)
|
||||
for _, p := range pkgs {
|
||||
if !declared[Owners[p]][p] {
|
||||
out = append(out, Finding{Module: Owners[p], Package: p, Owner: Owners[p], Missing: true})
|
||||
}
|
||||
}
|
||||
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.Missing {
|
||||
fmt.Printf("%s does not declare the package %s it owns (novox/hq ADR 0477 clause 4)\n", f.Owner, f.Package)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s declares the package %s: only %s may; require its provision instead (novox/hq ADR 0477 clause 4)\n",
|
||||
f.Module, f.Package, f.Owner)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("toolchains: go, nodejs, npm and buildah are declared only by their owners")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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
|
||||
}
|
||||
|
||||
func TestOnlyTheOwnerDeclaresAToolchainPackage(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"}]}`,
|
||||
"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"}]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Finding{{Module: "builder", Package: "nodejs", Owner: "nodejs"}, {Module: "lab", Package: "go", Owner: "go"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"go": `{"module":"go","resources":[]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Errorf("an owner missing its packages gave %v, want four findings (buildah, go, nodejs, npm)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it stands: this is the check itself, run here too.
|
||||
func TestTheCatalogueGivesEachToolchainPackageToItsOwnerAlone(t *testing.T) {
|
||||
got, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("the catalogue declares a toolchain package outside its owner: %v", 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
|
||||
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
|
||||
|
||||
+41
-3
@@ -48,11 +48,25 @@
|
||||
# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it.
|
||||
# 11. owned paths (novox/hq ADR 0422 §8, issue 1024; it holds ~/.ssh for ssh-client as ADR 0415 §6 asked):
|
||||
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
|
||||
# only under a home; and no module declares a path inside a directory of another module.
|
||||
# only under a home; no module declares a path inside a directory of another module; and none is, lies in or
|
||||
# holds a directory a system package owns under /var/lib (checks/owned/system, issue 1841).
|
||||
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
|
||||
# 12. the image builder (novox/hq ADR 0487 §3, issue 1704): only modules/buildah provides rootless-image-build.
|
||||
# 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory.
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in a covered module; the names are in
|
||||
# `particulars`, the covered modules in checks/particulars/covered.
|
||||
(cd checks/particulars && go test -count=1 . && go run . ../..)
|
||||
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
|
||||
(cd checks/toolchains && go test -count=1 . && go run . ../..)
|
||||
# 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 Node lock files (novox/hq ADR 0469 §5, issue 1868): a TypeScript module that names packages beyond the SDK commits its lock.
|
||||
(cd checks/locks && go test -count=1 . && go run . ../..)
|
||||
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
||||
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
||||
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
|
||||
@@ -60,8 +74,19 @@ set -eu
|
||||
# ~/.ssh (novox/hq ADR 0415 §6): only ssh-client may name a path under it. Main's merge check runs this line, so
|
||||
# checks/sshdir stays until main runs checks/owned; it goes under issue 1035.
|
||||
(cd checks/sshdir && go test -count=1 . && go run . ../..)
|
||||
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory.
|
||||
(cd checks/owned && go test -count=1 . && go run . ../..)
|
||||
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory;
|
||||
# none in a system package's directory under /var/lib (issue 1841), the known exceptions only shrinking: judged
|
||||
# against the trunk's copy of checks/owned/system-known at the merge base; a trunk without one is the list's first copy.
|
||||
owned_base=""
|
||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||
if git cat-file -e "$merge_base:checks/owned/system-known" 2>/dev/null; then
|
||||
owned_base=$(mktemp)
|
||||
git show "$merge_base:checks/owned/system-known" >"$owned_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/owned/system-known is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/owned && go test -count=1 . && if [ -n "$owned_base" ]; then go run . -base "$owned_base" ../..; else go run . ../..; fi)
|
||||
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The
|
||||
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
|
||||
secrets_base=""
|
||||
@@ -188,6 +213,19 @@ for m in $touched; do
|
||||
# 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"} ./... &&
|
||||
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
|
||||
if [ -n "$untestable" ]; then
|
||||
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
|
||||
|
||||
@@ -122,8 +122,8 @@ the login page again. The logout follows a logout link or form on the page; a pa
|
||||
|
||||
## The event
|
||||
|
||||
`scan.added`, said by this module, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body:
|
||||
`path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
|
||||
`scan.added`, said by the device process once the PDF is stored, before the file is written (the tool side files it afterwards, from the store, by a link the device signed; the verb's answer adds the file's `path`), on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body:
|
||||
`object` (the key of the stored PDF), `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
|
||||
`color-mode`, `cropped`, `pages` (1 for the glass), `scanned-at` (RFC 3339, UTC).
|
||||
|
||||
## Files it does not know
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
// Conversion before printing (novox/hq ADR 0468 clause 7, issue 1600). The printer takes PDF and JPEG. A stored
|
||||
// document of a type the module only-office converts (seat document-converter) is turned into a PDF first: the device
|
||||
// process signs a GET link on the document and a PUT link on <sha>.pdf with the binding's key, calls
|
||||
// document-converter.convert, checks that the PDF the converter says it put is the PDF the bucket holds, and prints
|
||||
// that. Any other type is refused, naming the types taken.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
)
|
||||
|
||||
// ConvertFollow is how long the device process follows one conversion, in calls of under 25 s each.
|
||||
const ConvertFollow = 3 * time.Minute
|
||||
|
||||
// ConvertTypes are the types document-converter takes (clause 7).
|
||||
var ConvertTypes = []string{"docx", "doc", "xlsx", "xls", "pptx", "ppt", "odt", "ods", "odp", "rtf", "txt", "html"}
|
||||
|
||||
// contentTypes are the media types of a fetched document, by the type the converter is asked for.
|
||||
var contentTypes = map[string]string{
|
||||
"text/html": "html", "application/xhtml+xml": "html", "text/plain": "txt", "application/rtf": "rtf", "text/rtf": "rtf",
|
||||
"application/msword": "doc", "application/vnd.ms-excel": "xls", "application/vnd.ms-powerpoint": "ppt",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
||||
"application/vnd.oasis.opendocument.text": "odt",
|
||||
"application/vnd.oasis.opendocument.spreadsheet": "ods",
|
||||
"application/vnd.oasis.opendocument.presentation": "odp",
|
||||
}
|
||||
|
||||
// the first bytes of an OLE compound file (the legacy Word, Excel and PowerPoint formats), and the stream names that
|
||||
// tell them apart, as UTF-16 (MS-CFB: directory entry names are UTF-16LE).
|
||||
var oleMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
|
||||
|
||||
func utf16le(s string) []byte {
|
||||
var b []byte
|
||||
for _, r := range s {
|
||||
b = append(b, byte(r), byte(r>>8))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ConvertibleType is the type a document is, among the converter's twelve, told by its bytes and, for a document that
|
||||
// came from a web server, by its Content-Type; "" when it is none of them. A document that is a PDF or a JPEG is
|
||||
// not asked about: the printer takes it as it is.
|
||||
func ConvertibleType(doc []byte, contentType string) string {
|
||||
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||
contentType = contentType[:i]
|
||||
}
|
||||
byHeader := contentTypes[strings.ToLower(strings.TrimSpace(contentType))]
|
||||
switch {
|
||||
case bytes.HasPrefix(doc, []byte("PK\x03\x04")):
|
||||
if t := zipType(doc); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
case bytes.HasPrefix(doc, oleMagic):
|
||||
for name, t := range map[string]string{"WordDocument": "doc", "Workbook": "xls", "PowerPoint Document": "ppt"} {
|
||||
if bytes.Contains(doc, utf16le(name)) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
case bytes.HasPrefix(doc, []byte(`{\rtf`)):
|
||||
return "rtf"
|
||||
}
|
||||
if byHeader == "html" || looksLikeHTML(doc) {
|
||||
if utf8.Valid(doc) && !bytes.Contains(doc, []byte{0}) {
|
||||
return "html"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if utf8.Valid(doc) && !bytes.Contains(doc, []byte{0}) && len(doc) > 0 {
|
||||
return "txt"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeHTML(doc []byte) bool {
|
||||
head := doc
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
s := strings.ToLower(strings.TrimLeft(strings.TrimPrefix(string(head), "\xef\xbb\xbf"), " \t\r\n"))
|
||||
return strings.HasPrefix(s, "<!doctype html") || strings.HasPrefix(s, "<html")
|
||||
}
|
||||
|
||||
// zipType tells the packages apart by their parts: OOXML by its main part (ECMA-376 Part 2), OpenDocument by its
|
||||
// first file `mimetype` (ODF 1.2 Part 3 §3.3).
|
||||
func zipType(doc []byte) string {
|
||||
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
switch f.Name {
|
||||
case "word/document.xml":
|
||||
return "docx"
|
||||
case "xl/workbook.xml":
|
||||
return "xlsx"
|
||||
case "ppt/presentation.xml":
|
||||
return "pptx"
|
||||
case "mimetype":
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
mt, _ := io.ReadAll(io.LimitReader(rc, 256))
|
||||
rc.Close()
|
||||
switch strings.TrimSpace(string(mt)) {
|
||||
case "application/vnd.oasis.opendocument.text":
|
||||
return "odt"
|
||||
case "application/vnd.oasis.opendocument.spreadsheet":
|
||||
return "ods"
|
||||
case "application/vnd.oasis.opendocument.presentation":
|
||||
return "odp"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toPDF turns a stored document of a converter type into a stored PDF and answers the PDF and its key. contentType is
|
||||
// the media type a web server gave, or "".
|
||||
func (w *deviceWorker) toPDF(ctx context.Context, objects Objects, key string, doc []byte, contentType string) ([]byte, string, error) {
|
||||
typ := ConvertibleType(doc, contentType)
|
||||
if typ == "" {
|
||||
return nil, "", fmt.Errorf("the document is neither a PDF nor a JPEG, nor a type the converter takes (%s): nothing is converted or printed",
|
||||
strings.Join(ConvertTypes, ", "))
|
||||
}
|
||||
signer, ok := objects.(Presigner)
|
||||
if !ok {
|
||||
return nil, "", errors.New("the bucket cannot sign a link, so the document cannot be converted")
|
||||
}
|
||||
if w.Convert == nil {
|
||||
return nil, "", errors.New("this process has no way to call the document converter")
|
||||
}
|
||||
sum := sha256Hex(doc)
|
||||
pdfKey := sum + ".pdf"
|
||||
source, err := signer.Link(http.MethodGet, key, objectlink.MaxExpires)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
target, err := signer.Link(http.MethodPut, pdfKey, objectlink.MaxExpires)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw, err := w.Convert(map[string]any{"source": source, "type": typ, "target": target, "sha256": sum})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("the document (%s) could not be converted: %w", typ, err)
|
||||
}
|
||||
// The converter answers within 25 s, the tool runner cutting every call at 30 s; a conversion not done by then is
|
||||
// answered as converting with its ask id, and is read again with convert {ask}, each call under 25 s, for as long
|
||||
// as the device process follows a conversion.
|
||||
follow := w.ConvertFollow
|
||||
if follow <= 0 {
|
||||
follow = ConvertFollow
|
||||
}
|
||||
until := time.Now().Add(follow)
|
||||
var ans struct {
|
||||
SHA256 string `json:"sha256"`
|
||||
Error string `json:"error"`
|
||||
State string `json:"state"`
|
||||
Ask string `json:"ask"`
|
||||
}
|
||||
for {
|
||||
ans.State, ans.Ask, ans.SHA256, ans.Error = "", "", "", ""
|
||||
if err := json.Unmarshal(raw, &ans); err != nil {
|
||||
return nil, "", fmt.Errorf("the converter's answer is not JSON: %w", err)
|
||||
}
|
||||
if ans.State != "converting" {
|
||||
break
|
||||
}
|
||||
if ans.Ask == "" || !time.Now().Before(until) {
|
||||
return nil, "", fmt.Errorf("the document (%s) was still converting after %s (ask %s): nothing is printed", typ, follow, ans.Ask)
|
||||
}
|
||||
if raw, err = w.Convert(map[string]any{"ask": ans.Ask}); err != nil {
|
||||
return nil, "", fmt.Errorf("the document (%s) could not be converted: %w", typ, err)
|
||||
}
|
||||
}
|
||||
if ans.Error != "" {
|
||||
return nil, "", fmt.Errorf("the document (%s) could not be converted: %s", typ, ans.Error)
|
||||
}
|
||||
pdf, err := objects.Get(ctx, pdfKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("the converter answered, but no PDF is kept as %s: %w", pdfKey, err)
|
||||
}
|
||||
if got := sha256Hex(pdf); !strings.EqualFold(got, ans.SHA256) {
|
||||
return nil, "", fmt.Errorf("the converter says it put a PDF of SHA-256 %s, but the bucket holds one of %s as %s: nothing is printed", ans.SHA256, got, pdfKey)
|
||||
}
|
||||
if _, _, err := SniffFormat(pdf); err != nil {
|
||||
return nil, "", fmt.Errorf("the converter's result %s: %w", pdfKey, err)
|
||||
}
|
||||
return pdf, pdfKey, nil
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// docx builds a minimal Word package: the parts ECMA-376 Part 2 requires of one ([Content_Types].xml, the package
|
||||
// relationships and the main part word/document.xml). No real Word file is captured; the layout is the standard's.
|
||||
func docx(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, body := range map[string]string{
|
||||
"[Content_Types].xml": `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>`,
|
||||
"_rels/.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>`,
|
||||
"word/document.xml": `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>`,
|
||||
} {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte(body))
|
||||
}
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// converterFake stands for document-converter.convert: it records the call and, as the converter process would, puts a
|
||||
// PDF in the bucket under the key the target link names and answers its size and SHA-256.
|
||||
type converterFake struct {
|
||||
r *printerRig
|
||||
calls []map[string]any
|
||||
pdf []byte
|
||||
answer func(body map[string]any, stored []byte) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func withConverter(r *printerRig, pdf []byte) *converterFake {
|
||||
c := &converterFake{r: r, pdf: pdf}
|
||||
r.worker.Convert = func(body map[string]any) (json.RawMessage, error) {
|
||||
c.calls = append(c.calls, body)
|
||||
if body["target"] != nil { // a start puts the PDF; a read of an ask (convert {ask}) puts nothing
|
||||
target, _ := url.Parse(body["target"].(string))
|
||||
r.objects.Put(context.Background(), strings.TrimPrefix(target.Path, "/"), "application/pdf", c.pdf)
|
||||
}
|
||||
if c.answer != nil {
|
||||
return c.answer(body, c.pdf)
|
||||
}
|
||||
return json.Marshal(map[string]any{"size": len(c.pdf), "sha256": sha256Hex(c.pdf)})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 1600/AC1: a stored .docx is converted: document-converter.convert is called with a GET link on the source and a PUT
|
||||
// link on <sha>.pdf, both signed in the bucket and expiring within ten minutes, and the PDF is printed.
|
||||
func TestAStoredDocxIsConvertedThenPrinted(t *testing.T) {
|
||||
fake := newIPPFake(t, 51, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := docx(t)
|
||||
key := sha256Hex(doc)
|
||||
r.objects.Put(t.Context(), key, "application/octet-stream", doc)
|
||||
pdf := pdfOf(2500)
|
||||
conv := withConverter(r, pdf)
|
||||
got, err := r.call(t, "printer.print", map[string]any{"object": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(conv.calls) != 1 {
|
||||
t.Fatalf("%d converter calls", len(conv.calls))
|
||||
}
|
||||
call := conv.calls[0]
|
||||
src, _ := url.Parse(call["source"].(string))
|
||||
dst, _ := url.Parse(call["target"].(string))
|
||||
if src.Path != "/"+key || dst.Path != "/"+key+".pdf" || call["type"] != "docx" || call["sha256"] != key {
|
||||
t.Errorf("call = %v", call)
|
||||
}
|
||||
for _, u := range []*url.URL{src, dst} {
|
||||
if secs, _ := strconv.Atoi(u.Query().Get("X-Amz-Expires")); secs < 1 || time.Duration(secs)*time.Second > 10*time.Minute {
|
||||
t.Errorf("%s lives %d s", u, secs)
|
||||
}
|
||||
}
|
||||
if strings.Join(r.objects.links, ",") != "GET "+key+" 600,PUT "+key+".pdf 600" {
|
||||
t.Errorf("links signed: %v", r.objects.links)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["job-id"] != float64(51) || m["format"] != "application/pdf" || m["object"] != key+".pdf" || m["converted-from"] != key {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
if _, sent := fake.request(t); string(sent) != string(pdf) {
|
||||
t.Error("the printer was not sent the converted PDF")
|
||||
}
|
||||
}
|
||||
|
||||
// 1600/AC2: a converter answer whose SHA-256 differs from the stored PDF's is refused and nothing is printed.
|
||||
func TestAConverterAnswerOfAnotherHashPrintsNothing(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := docx(t)
|
||||
r.objects.Put(t.Context(), sha256Hex(doc), "application/octet-stream", doc)
|
||||
conv := withConverter(r, pdfOf(900))
|
||||
conv.answer = func(map[string]any, []byte) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"size": 900, "sha256": sha256Hex([]byte("another pdf"))})
|
||||
}
|
||||
_, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(doc)})
|
||||
if err == nil || !strings.Contains(err.Error(), "nothing is printed") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if fake.body != nil {
|
||||
t.Error("something was printed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1600/AC3: a converter error is answered with its code and meaning.
|
||||
func TestAConverterErrorIsAnsweredWithItsCodeAndMeaning(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := docx(t)
|
||||
r.objects.Put(t.Context(), sha256Hex(doc), "application/octet-stream", doc)
|
||||
conv := withConverter(r, pdfOf(10))
|
||||
conv.answer = func(map[string]any, []byte) (json.RawMessage, error) {
|
||||
return nil, errors.New("the document server refused the conversion, error -4: error while downloading the document file to be converted")
|
||||
}
|
||||
_, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(doc)})
|
||||
if err == nil || !strings.Contains(err.Error(), "-4: error while downloading the document file to be converted") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
// An error the converter wrote in its answer is the same.
|
||||
conv.answer = func(map[string]any, []byte) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"error": "-3: conversion error"}`), nil
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(doc)}); err == nil || !strings.Contains(err.Error(), "-3: conversion error") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if fake.body != nil {
|
||||
t.Error("something was printed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1600/AC4: a type that is neither PDF, JPEG nor one of the converter's twelve is refused naming the types taken, and
|
||||
// convert is not called.
|
||||
func TestAnotherTypeIsRefusedNamingTheTypesTaken(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
exe := []byte("MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff\x00\x00")
|
||||
r.objects.Put(t.Context(), sha256Hex(exe), "application/octet-stream", exe)
|
||||
conv := withConverter(r, pdfOf(10))
|
||||
_, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(exe)})
|
||||
if err == nil || !strings.Contains(err.Error(), "docx, doc, xlsx, xls, pptx, ppt, odt, ods, odp, rtf, txt, html") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(conv.calls) != 0 || fake.body != nil {
|
||||
t.Errorf("%d converter calls, printed %v", len(conv.calls), fake.body != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 1600: each of the twelve types is told by its bytes (and a web page also by its Content-Type).
|
||||
func TestTheTypesAreToldByTheirBytes(t *testing.T) {
|
||||
ole := func(stream string) []byte {
|
||||
return append(append([]byte{}, oleMagic...), append(make([]byte, 64), utf16le(stream)...)...)
|
||||
}
|
||||
odf := func(mime string) []byte {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, _ := zw.Create("mimetype")
|
||||
w.Write([]byte(mime))
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
ooxml := func(part string) []byte {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, _ := zw.Create(part)
|
||||
w.Write([]byte("<x/>"))
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
for want, c := range map[string]struct {
|
||||
doc []byte
|
||||
ct string
|
||||
}{
|
||||
"docx": {docx(t), ""}, "xlsx": {ooxml("xl/workbook.xml"), ""}, "pptx": {ooxml("ppt/presentation.xml"), ""},
|
||||
"doc": {ole("WordDocument"), ""}, "xls": {ole("Workbook"), ""}, "ppt": {ole("PowerPoint Document"), ""},
|
||||
"odt": {odf("application/vnd.oasis.opendocument.text"), ""}, "ods": {odf("application/vnd.oasis.opendocument.spreadsheet"), ""},
|
||||
"odp": {odf("application/vnd.oasis.opendocument.presentation"), ""},
|
||||
"rtf": {[]byte(`{\rtf1\ansi hello}`), ""}, "txt": {[]byte("just some words\n"), ""},
|
||||
"html": {[]byte("<!DOCTYPE html><html><body>hi</body></html>"), ""}, "html ": {[]byte("<p>a fragment</p>"), "text/html; charset=utf-8"},
|
||||
} {
|
||||
if got := ConvertibleType(c.doc, c.ct); got != strings.TrimSpace(want) {
|
||||
t.Errorf("%s: told as %q", want, got)
|
||||
}
|
||||
}
|
||||
for name, doc := range map[string][]byte{"empty": {}, "binary": {0, 1, 2, 3}, "a zip of nothing known": ooxml("other.xml"), "ole of nothing known": ole("Other")} {
|
||||
if got := ConvertibleType(doc, ""); got != "" {
|
||||
t.Errorf("%s: told as %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1600/AC5: the device process invokes seat:document-converter.convert.
|
||||
func TestTheDeviceProcessInvokesTheConverter(t *testing.T) {
|
||||
for _, r := range readManifest(t).Resources {
|
||||
if r["id"] == "device" {
|
||||
inv, _ := r["bus"].(map[string]any)["invokes"].([]any)
|
||||
if len(inv) != 1 || inv[0] != "seat:document-converter.convert" {
|
||||
t.Errorf("invokes = %v", inv)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("no device process")
|
||||
}
|
||||
|
||||
// 1600 (review round 1): a conversion answered as converting is followed with convert {ask}, each call its own, until
|
||||
// it is done; one still converting after the device process's bound is not printed, and the process.CallTimeout of the
|
||||
// SDK is not touched.
|
||||
func TestAConversionAnsweredAsConvertingIsFollowedWithItsAsk(t *testing.T) {
|
||||
fake := newIPPFake(t, 61, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := docx(t)
|
||||
key := sha256Hex(doc)
|
||||
r.objects.Put(t.Context(), key, "application/octet-stream", doc)
|
||||
pdf := pdfOf(700)
|
||||
conv := withConverter(r, pdf)
|
||||
rounds := 0
|
||||
conv.answer = func(body map[string]any, stored []byte) (json.RawMessage, error) {
|
||||
if body["ask"] != nil {
|
||||
rounds++
|
||||
if rounds < 3 {
|
||||
return json.RawMessage(`{"state": "converting", "ask": "` + body["ask"].(string) + `"}`), nil
|
||||
}
|
||||
return json.Marshal(map[string]any{"size": len(stored), "sha256": sha256Hex(stored)})
|
||||
}
|
||||
return json.RawMessage(`{"state": "converting", "ask": "a1b2c3a1b2c3a1b2c3a1b2c3"}`), nil
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(conv.calls) != 4 || conv.calls[1]["ask"] != "a1b2c3a1b2c3a1b2c3a1b2c3" || conv.calls[1]["source"] != nil {
|
||||
t.Errorf("calls = %v", conv.calls)
|
||||
}
|
||||
if _, sent := fake.request(t); string(sent) != string(pdf) {
|
||||
t.Error("the converted PDF was not printed")
|
||||
}
|
||||
|
||||
// Always converting: given up at the bound, nothing printed.
|
||||
fake.body = nil
|
||||
r.worker.ConvertFollow = 30 * time.Millisecond
|
||||
conv.answer = func(body map[string]any, _ []byte) (json.RawMessage, error) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
return json.RawMessage(`{"state": "converting", "ask": "a1b2c3a1b2c3a1b2c3a1b2c3"}`), nil
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "still converting") || fake.body != nil {
|
||||
t.Errorf("error = %v, printed %v", err, fake.body != nil)
|
||||
}
|
||||
src, _ := os.ReadFile("deviceproc.go")
|
||||
if strings.Contains(string(src), "CallTimeout") {
|
||||
t.Error("deviceproc.go sets the SDK's call timeout")
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,17 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
@@ -66,6 +69,12 @@ type deviceWorker struct {
|
||||
Node string
|
||||
// Capture scans; nil is the device's own (a test puts a stand-in).
|
||||
Capture func(ctx context.Context, req ScanRequest) ([]Capture, error)
|
||||
// Convert calls the seat document-converter's convert verb as this process's own account (clause 7); nil: none.
|
||||
Convert func(body map[string]any) (json.RawMessage, error)
|
||||
// ConvertFollow is how long a conversion is followed; zero is ConvertFollow (a test makes it shorter).
|
||||
ConvertFollow time.Duration
|
||||
// Fetcher is the guarded fetch of a web link (fetch.go); it reads its facts when it is first needed.
|
||||
Fetcher func() (*Fetcher, error)
|
||||
// answerPart is the raw size of a document part in an answer; zero is AnswerPartSize (a test makes it larger).
|
||||
answerPart int
|
||||
Now func() time.Time
|
||||
@@ -247,10 +256,14 @@ func (w *deviceWorker) keep(ctx context.Context, objects Objects, set Settings,
|
||||
|
||||
// keepOne keeps one captured PDF and announces it.
|
||||
func (w *deviceWorker) keepOne(ctx context.Context, objects Objects, set Settings, c Capture) (map[string]any, error) {
|
||||
// A scan the tool could not take is not kept nor announced: an announced scan is always filed (issue 1539).
|
||||
if most := MostAnswerParts * AnswerPartSize; len(c.PDF) > most {
|
||||
return nil, fmt.Errorf("the scan's PDF is %d bytes, over the %d the tool can fetch (%d parts of %d): it is not kept; scan with a lower resolution or fewer sheets",
|
||||
len(c.PDF), most, MostAnswerParts, AnswerPartSize)
|
||||
// A scan over a document's limit is not kept nor announced: an announced scan is always filed (issue 1539).
|
||||
if len(c.PDF) > int(objectlink.MaxDocument) {
|
||||
return nil, fmt.Errorf("the scan's PDF is %d bytes, over the %d a document may be (novox/hq ADR 0468 §3): it is not kept; scan with a lower resolution or fewer sheets",
|
||||
len(c.PDF), objectlink.MaxDocument)
|
||||
}
|
||||
signer, ok := objects.(Presigner)
|
||||
if !ok {
|
||||
return nil, errors.New("the bucket cannot sign a link: the scan is not kept")
|
||||
}
|
||||
body := c.Body(w.Node, set.ColorMode)
|
||||
key := body["object"].(string)
|
||||
@@ -260,7 +273,13 @@ func (w *deviceWorker) keepOne(ctx context.Context, objects Objects, set Setting
|
||||
if err := w.Emit(body, c.SHA256); err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s, but %s could not be said on the bus: %w", key, EventAdded, err)
|
||||
}
|
||||
answer := map[string]any{"key": key, "size": len(c.PDF), "parts": answerPartCount(len(c.PDF))}
|
||||
// The answer carries a link to read the stored PDF, signed with the binding's key (novox/hq ADR 0468 §4): the tool
|
||||
// side reads the scan by it and files it; no byte of the document is in the answer.
|
||||
link, err := signer.Link(http.MethodGet, key, objectlink.MaxExpires)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s, but a link to it could not be signed: %w", key, err)
|
||||
}
|
||||
answer := map[string]any{"key": key, "size": len(c.PDF), "link": link, "link-seconds": int(objectlink.MaxExpires / time.Second)}
|
||||
for k, v := range body {
|
||||
answer[k] = v
|
||||
}
|
||||
@@ -370,7 +389,20 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro
|
||||
return nil, err
|
||||
}
|
||||
key := ask.Object
|
||||
if key == "" {
|
||||
var doc []byte
|
||||
contentType := ""
|
||||
switch {
|
||||
case ask.URL != "":
|
||||
fetched, err := w.fetch(ctx, ask.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The fetched document is kept once under its SHA-256, as a scan is, and printed as that object.
|
||||
doc, key, contentType = fetched.Body, sha256Hex(fetched.Body), fetched.ContentType
|
||||
if err := Store(ctx, objects, key, fetched.ContentType, doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case key == "":
|
||||
for _, ext := range []string{"pdf", "jpg"} {
|
||||
k := "uploads/" + ask.Upload + "." + ext
|
||||
if has, err := objects.Has(ctx, k); err != nil {
|
||||
@@ -383,21 +415,55 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("upload %s is not complete: no document is kept under it", ask.Upload)
|
||||
}
|
||||
}
|
||||
doc, err := objects.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
fallthrough
|
||||
default:
|
||||
var err error
|
||||
if doc, err = objects.Get(ctx, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
format, _, err := SniffFormat(doc)
|
||||
var convertedFrom string
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Not a PDF or a JPEG: a type the converter takes becomes a PDF first (clause 7), any other is refused.
|
||||
pdf, pdfKey, cerr := w.toPDF(ctx, objects, key, doc, contentType)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
doc, convertedFrom, key = pdf, key, pdfKey
|
||||
format = "application/pdf"
|
||||
}
|
||||
// The printer is asked what it takes before the job, and a format it does not list is refused with the list
|
||||
// (novox/hq ADR 0468 clause 5): a printer that cannot be asked prints nothing.
|
||||
listed, err := FormatsOf(ctx, set.Printer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the printer's document formats could not be read, so nothing is sent: %w", err)
|
||||
}
|
||||
if !slices.Contains(listed, format) {
|
||||
return nil, fmt.Errorf("the printer does not take %s: its document-format-supported lists %s", format, strings.Join(listed, ", "))
|
||||
}
|
||||
res, err := PrintJob(ctx, set.Printer, PrintOptions{Format: format, Copies: ask.Copies, Sides: ask.Sides, Media: ask.Media, Name: "brother print"}, doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"job-id": res.JobID, "job-state": res.State, "job-state-code": res.StateCode,
|
||||
"job-state-reasons": res.Reasons, "format": format, "copies": ask.Copies, "sides": ask.Sides, "media": ask.Media, "object": key}, nil
|
||||
out := map[string]any{"job-id": res.JobID, "job-state": res.State, "job-state-code": res.StateCode,
|
||||
"job-state-reasons": res.Reasons, "format": format, "copies": ask.Copies, "sides": ask.Sides, "media": ask.Media, "object": key}
|
||||
if convertedFrom != "" {
|
||||
out["converted-from"] = convertedFrom
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetch gets a web link under the rules of fetch.go.
|
||||
func (w *deviceWorker) fetch(ctx context.Context, link string) (*Fetched, error) {
|
||||
if w.Fetcher == nil {
|
||||
return nil, errors.New("this process has no way to fetch a web link")
|
||||
}
|
||||
f, err := w.Fetcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Fetch(ctx, link)
|
||||
}
|
||||
|
||||
// read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are
|
||||
@@ -497,7 +563,7 @@ func nonNilForms(f []Form) []Form {
|
||||
// It refuses to start naming what is missing when the account cannot be read, and never logs a password.
|
||||
func device() int {
|
||||
settings, passwordFile, brokerFile := os.Getenv("BROTHER_SETTINGS"), os.Getenv("BROTHER_ADMIN_PASSWORD"), os.Getenv("BROTHER_BROKER")
|
||||
store := os.Getenv("BROTHER_STORE")
|
||||
store, fetchFile := os.Getenv("BROTHER_STORE"), os.Getenv("BROTHER_FETCH")
|
||||
if brokerFile == "" {
|
||||
fmt.Fprintln(os.Stderr, "[brother] device was started without BROTHER_BROKER, its bus account's file")
|
||||
return 2
|
||||
@@ -519,6 +585,13 @@ func device() int {
|
||||
}
|
||||
return NewS3(facts), nil
|
||||
},
|
||||
Fetcher: func() (*Fetcher, error) {
|
||||
r, err := ReadFetchFacts(fetchFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewFetcher(r), nil
|
||||
},
|
||||
Now: time.Now,
|
||||
Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) },
|
||||
}
|
||||
@@ -541,6 +614,9 @@ func runDevice(ctx context.Context, account process.Account, w *deviceWorker) er
|
||||
}
|
||||
defer conn.Close()
|
||||
w.Answers = conn.State(AnswersState)
|
||||
w.Convert = func(body map[string]any) (json.RawMessage, error) {
|
||||
return conn.Call("seat:document-converter.convert", body)
|
||||
}
|
||||
if w.Node == "" {
|
||||
w.Node = account.Node
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestScannerDocumentGivesEachPartOfAKeptScan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC10: a scan of more than one part is fetched whole by scanner.scan, joined, checked and filed.
|
||||
// 1453/AC10, 1601: a scan the size of several answer parts is read whole from the store by scanner.scan, checked and filed.
|
||||
func TestScanNowFetchesAScanOfSeveralParts(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: false})
|
||||
@@ -266,15 +266,41 @@ func TestAnIncompleteUploadIsDroppedAfterAWhile(t *testing.T) {
|
||||
// ippFake is a printer's IPP port: it records the request and answers a Print-Job answer with a job id and state.
|
||||
type ippFake struct {
|
||||
url string
|
||||
body []byte
|
||||
body []byte // the Print-Job request
|
||||
status int
|
||||
// formats is document-format-supported, as the printer lists it; asked counts the Get-Printer-Attributes requests.
|
||||
formats []string
|
||||
asked int
|
||||
// delay is how long a Print-Job takes to be answered.
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func newIPPFake(t *testing.T, jobID, jobState int) *ippFake {
|
||||
t.Helper()
|
||||
f := &ippFake{}
|
||||
f := &ippFake{formats: []string{"application/octet-stream", "application/pdf", "image/jpeg"}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.body, _ = io.ReadAll(r.Body)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if binary.BigEndian.Uint16(body[2:4]) == uint16(ippGetPrinterAttributes) {
|
||||
// Get-Printer-Attributes (RFC 8011 §4.2.5): the formats are one attribute of several values, a value
|
||||
// after the first carrying an empty name (§4.1.4), each of the mimeMediaType syntax.
|
||||
f.asked++
|
||||
ans := []byte{0x02, 0x00, 0x00, 0x00, 0, 0, 0, 1, ippOperation}
|
||||
ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8")
|
||||
ans = append(ans, 0x04) // printer-attributes-tag, RFC 8010 §3.5.1
|
||||
for i, format := range f.formats {
|
||||
name := "document-format-supported"
|
||||
if i > 0 {
|
||||
name = ""
|
||||
}
|
||||
ans = appendAttr(ans, ippMimeType, name, format)
|
||||
}
|
||||
ans = append(ans, ippEnd)
|
||||
w.Header().Set("Content-Type", "application/ipp")
|
||||
w.Write(ans)
|
||||
return
|
||||
}
|
||||
f.body = body
|
||||
time.Sleep(f.delay)
|
||||
ans := []byte{0x02, 0x00, byte(f.status >> 8), byte(f.status), 0, 0, 0, 1, ippOperation}
|
||||
ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8")
|
||||
ans = append(ans, ippJob)
|
||||
@@ -337,15 +363,14 @@ func TestPrinterPrintSendsAPrintJobAndAnswersTheJob(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; an upload is printed by its id.
|
||||
func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) {
|
||||
// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; a document stored under its
|
||||
// SHA-256 (as node-files.upload keeps one) is printed by its key.
|
||||
func TestPrinterPrintDefaultsAndFormatsAndStoredDocuments(t *testing.T) {
|
||||
fake := newIPPFake(t, 8, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{9}, 200)...)
|
||||
if _, err := upload(t, r, "photo", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := r.call(t, "printer.print", map[string]any{"upload": "photo"})
|
||||
r.objects.Put(t.Context(), sha256Hex(jpg), "image/jpeg", jpg)
|
||||
got, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(jpg)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -357,8 +382,8 @@ func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) {
|
||||
strings.Join(attrs.texts("sides"), "") != "one-sided" || strings.Join(attrs.texts("media"), "") != DefaultMedia || attrs.Attributes["copies"][0].Integer != 1 {
|
||||
t.Errorf("attributes = %v, %d bytes sent", attrs.Attributes, len(sent))
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"upload": "never-uploaded"}); err == nil || !strings.Contains(err.Error(), "not complete") {
|
||||
t.Errorf("an upload never made: error = %v", err)
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex([]byte("never stored"))}); err == nil {
|
||||
t.Errorf("an object never stored was printed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,9 +417,9 @@ func TestPrinterPrintRefusesWhatIsNotWellFormed(t *testing.T) {
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": dk}); err == nil || !strings.Contains(err.Error(), "0x0400") {
|
||||
t.Errorf("a refused job: error = %v", err)
|
||||
}
|
||||
// A kept object that is not a document the printer takes is not sent.
|
||||
// A kept object that is no document the printer takes, nor one the converter takes (1600), is not sent.
|
||||
fake.status, fake.body = 0, nil
|
||||
r.objects.Put(t.Context(), key, "text/plain", []byte("hello"))
|
||||
r.objects.Put(t.Context(), key, "application/octet-stream", []byte{0x00, 0x01, 0xFE, 0xFF, 0x02})
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") || fake.body != nil {
|
||||
t.Errorf("a text object: error = %v, sent %d bytes", err, len(fake.body))
|
||||
}
|
||||
@@ -547,83 +572,3 @@ func home(t *testing.T) string {
|
||||
t.Setenv("HOME", h)
|
||||
return h
|
||||
}
|
||||
|
||||
// 1551: printer.print with a path reads the file, uploads it in parts and prints it in one call; a document of
|
||||
// several parts arrives whole, and the answer has the job's id and state.
|
||||
func TestPrinterPrintTakesAPathAndUploadsAndPrintsInOneCall(t *testing.T) {
|
||||
fake := newIPPFake(t, 31, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := pdfOf(2*PartSize + 1234)
|
||||
file := filepath.Join(home(t), "letter.bin") // the name says nothing: the bytes are read
|
||||
if err := os.WriteFile(file, doc, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": file, "copies": float64(2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["job-id"] != float64(31) || m["job-state"] != "processing" || m["format"] != "application/pdf" || m["bytes"] != len(doc) {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
attrs, sent := fake.request(t)
|
||||
if !bytes.Equal(sent, doc) || attrs.Attributes["copies"][0].Integer != 2 {
|
||||
t.Errorf("sent %d bytes (want %d), attributes %v", len(sent), len(doc), attrs.Attributes)
|
||||
}
|
||||
ops := r.bus.ops()
|
||||
if len(ops) != 4 || ops[0] != OpUpload || ops[2] != OpUpload || ops[3] != OpPrint {
|
||||
t.Errorf("asks = %v, want three upload parts and a print", ops)
|
||||
}
|
||||
}
|
||||
|
||||
// 1551: only a regular file under the tool account's home folder is read, after symbolic links are resolved; every
|
||||
// other path is refused in one fixed way that says nothing of whether it exists or how large it is; a file in the
|
||||
// home folder that is no PDF or JPEG by its bytes is refused by name of the type.
|
||||
func TestPrinterPrintReadsOnlyAFileUnderTheHomeFolder(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
h := home(t)
|
||||
outside := t.TempDir()
|
||||
secret := filepath.Join(outside, "secret.pdf")
|
||||
os.WriteFile(secret, pdfOf(3000), 0o600)
|
||||
link := filepath.Join(h, "link.pdf")
|
||||
os.Symlink(secret, link)
|
||||
dirLink := filepath.Join(h, "dirlink")
|
||||
os.Symlink(outside, dirLink)
|
||||
os.Mkdir(filepath.Join(h, "folder"), 0o700)
|
||||
for name, path := range map[string]string{
|
||||
"outside home": secret,
|
||||
"link out of home": link,
|
||||
"folder link out": filepath.Join(dirLink, "secret.pdf"),
|
||||
"dot-dot out of home": filepath.Join(h, "..", filepath.Base(outside), "secret.pdf"),
|
||||
"missing in home": filepath.Join(h, "none.pdf"),
|
||||
"missing outside home": filepath.Join(outside, "none.pdf"),
|
||||
"folder": filepath.Join(h, "folder"),
|
||||
"the home folder itself": h,
|
||||
"relative": "secret.pdf",
|
||||
"proc": "/proc/self/environ",
|
||||
} {
|
||||
_, err := r.call(t, "printer.print", map[string]any{"path": path})
|
||||
if err == nil || err.Error() != PathRefused {
|
||||
t.Errorf("%s: error = %v, want exactly %q", name, err, PathRefused)
|
||||
}
|
||||
}
|
||||
text := filepath.Join(h, "fake.pdf")
|
||||
os.WriteFile(text, []byte("just text, named .pdf"), 0o600)
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"path": text}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") {
|
||||
t.Errorf("text in home: error = %v", err)
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"path": text, "object": sha256Hex([]byte("x")) + ".pdf"}); err == nil {
|
||||
t.Error("path with object accepted")
|
||||
}
|
||||
if r.bus.asks() != 0 || fake.body != nil {
|
||||
t.Errorf("%d asks queued, printer sent %d bytes", r.bus.asks(), len(fake.body))
|
||||
}
|
||||
// A symbolic link inside the home folder to a file inside it is fine.
|
||||
inside := filepath.Join(h, "real.pdf")
|
||||
os.WriteFile(inside, pdfOf(3000), 0o600)
|
||||
os.Symlink(inside, filepath.Join(h, "ok.pdf"))
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"path": filepath.Join(h, "ok.pdf")}); err != nil {
|
||||
t.Errorf("a link within home: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
// A web link the device process fetches (novox/hq ADR 0468 clause 6, issue 1599). The process holds the bucket's key and
|
||||
// the printer's password and sits inside the mesh, so a URL from any caller would otherwise reach the store, the
|
||||
// printer's admin pages or the controller. Only http and https are taken. The fetch takes at most 60 s, 100 MiB and 5
|
||||
// redirects. Each host is resolved once, every address it gives is checked, and the connection is made to an address
|
||||
// that was checked, never to a second lookup's. Loopback, private (RFC 1918, RFC 4193), link-local, shared (RFC 6598),
|
||||
// unspecified, multicast and the mesh's own tunnel range are refused, after every redirect too.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
)
|
||||
|
||||
const (
|
||||
// FetchTime, FetchMost and FetchRedirects are the fetch's bounds (ADR 0468 clause 6).
|
||||
FetchTime = 60 * time.Second
|
||||
FetchMost = objectlink.MaxDocument
|
||||
FetchRedirects = 5
|
||||
// MostURL is the longest address printer.print takes.
|
||||
MostURL = 2048
|
||||
)
|
||||
|
||||
// FetchFacts is the file the mesh writes for the fetch (files/fetch.json): the tunnel's range, which a module cannot
|
||||
// know (mesh-sdk go/catalogue/machine_into_files.go, the fact `mesh-range`).
|
||||
type FetchFacts struct {
|
||||
MeshRange string `json:"mesh-range"`
|
||||
}
|
||||
|
||||
// ReadFetchFacts reads the fetch's facts; a file that names no range is refused, since a fetch that does not know the
|
||||
// mesh's range could reach it.
|
||||
func ReadFetchFacts(path string) (netip.Prefix, error) {
|
||||
if path == "" {
|
||||
return netip.Prefix{}, errors.New("the module was started without the fetch's facts (BROTHER_FETCH)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("the fetch's facts cannot be read: %w", err)
|
||||
}
|
||||
var f FetchFacts
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("the fetch's facts are not the module's: %v", err)
|
||||
}
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(f.MeshRange))
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("the fetch's facts name no mesh range (%q): %v", f.MeshRange, err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CheckURLScheme is what the tool side can say of an address without looking at the network: it is http or https.
|
||||
func CheckURLScheme(raw string) error {
|
||||
if len(raw) > MostURL {
|
||||
return fmt.Errorf("url is at most %d characters", MostURL)
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("url %q is not an address", raw)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("url %q has the scheme %q: only http and https are fetched (novox/hq ADR 0468 clause 6)", raw, u.Scheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var refusedRanges = []struct {
|
||||
prefix netip.Prefix
|
||||
class string
|
||||
}{
|
||||
{netip.MustParsePrefix("0.0.0.0/8"), "an unspecified address"},
|
||||
{netip.MustParsePrefix("10.0.0.0/8"), "a private address (RFC 1918)"},
|
||||
{netip.MustParsePrefix("100.64.0.0/10"), "a shared address (RFC 6598)"},
|
||||
{netip.MustParsePrefix("127.0.0.0/8"), "a loopback address"},
|
||||
{netip.MustParsePrefix("169.254.0.0/16"), "a link-local address"},
|
||||
{netip.MustParsePrefix("172.16.0.0/12"), "a private address (RFC 1918)"},
|
||||
{netip.MustParsePrefix("192.168.0.0/16"), "a private address (RFC 1918)"},
|
||||
{netip.MustParsePrefix("224.0.0.0/4"), "a multicast address"},
|
||||
{netip.MustParsePrefix("::/128"), "an unspecified address"},
|
||||
{netip.MustParsePrefix("::1/128"), "a loopback address"},
|
||||
{netip.MustParsePrefix("fc00::/7"), "a private address (RFC 4193)"},
|
||||
{netip.MustParsePrefix("fe80::/10"), "a link-local address"},
|
||||
{netip.MustParsePrefix("ff00::/8"), "a multicast address"},
|
||||
}
|
||||
|
||||
// Refusal says why an address may not be fetched from, or "" when it may.
|
||||
func (f *Fetcher) Refusal(a netip.Addr) string {
|
||||
a = a.Unmap()
|
||||
for _, r := range refusedRanges {
|
||||
if r.prefix.Contains(a) {
|
||||
return r.class
|
||||
}
|
||||
}
|
||||
if f.MeshRange.IsValid() && f.MeshRange.Contains(a) {
|
||||
return "an address of the mesh's own network"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Fetcher fetches a web link under the rules above. Resolve and Dial are what a test replaces.
|
||||
type Fetcher struct {
|
||||
MeshRange netip.Prefix
|
||||
Resolve func(ctx context.Context, host string) ([]netip.Addr, error)
|
||||
Dial func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
Timeout time.Duration
|
||||
Most int64
|
||||
Redirects int
|
||||
}
|
||||
|
||||
// NewFetcher is a fetcher with the real resolver and dialer and the bounds of the ADR.
|
||||
func NewFetcher(meshRange netip.Prefix) *Fetcher {
|
||||
d := &net.Dialer{Timeout: 15 * time.Second}
|
||||
return &Fetcher{MeshRange: meshRange, Dial: d.DialContext, Timeout: FetchTime, Most: FetchMost, Redirects: FetchRedirects,
|
||||
Resolve: func(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
}}
|
||||
}
|
||||
|
||||
// Fetched is a document fetched: its bytes and the media type the server gave.
|
||||
type Fetched struct {
|
||||
Body []byte
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Fetch gets the document at the address. Every refusal names why and happens before a connection to the refused
|
||||
// address is made; a body over the limit or a fetch over the time stores nothing, since nothing is returned.
|
||||
func (f *Fetcher) Fetch(ctx context.Context, raw string) (*Fetched, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, f.Timeout)
|
||||
defer cancel()
|
||||
current := raw
|
||||
for hop := 0; ; hop++ {
|
||||
if err := CheckURLScheme(current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, _ := url.Parse(current)
|
||||
addr, err := f.checked(ctx, u.Hostname())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", current, err)
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
|
||||
}
|
||||
target := net.JoinHostPort(addr.String(), port)
|
||||
tr := &http.Transport{
|
||||
Proxy: nil, // never the environment's proxy: the address checked is the address dialled
|
||||
DisableKeepAlives: true,
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return f.Dial(ctx, network, target)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: tr, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, current, nil)
|
||||
if err != nil {
|
||||
tr.CloseIdleConnections()
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
tr.CloseIdleConnections()
|
||||
return nil, fmt.Errorf("%s could not be fetched: %w", current, err)
|
||||
}
|
||||
if resp.StatusCode >= 300 && resp.StatusCode < 400 && resp.Header.Get("Location") != "" {
|
||||
loc, lerr := u.Parse(resp.Header.Get("Location"))
|
||||
resp.Body.Close()
|
||||
tr.CloseIdleConnections()
|
||||
if lerr != nil {
|
||||
return nil, fmt.Errorf("%s redirects to an address that is not one: %v", current, lerr)
|
||||
}
|
||||
if hop >= f.Redirects {
|
||||
return nil, fmt.Errorf("%s is redirected more than %d times", raw, f.Redirects)
|
||||
}
|
||||
current = loc.String()
|
||||
continue
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s answered %s", current, resp.Status)
|
||||
}
|
||||
if resp.ContentLength > f.Most {
|
||||
return nil, fmt.Errorf("%s is %d bytes, over the %d a document may be (novox/hq ADR 0468 clause 6)", current, resp.ContentLength, f.Most)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, f.Most+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s could not be read within %s: %w", current, f.Timeout, err)
|
||||
}
|
||||
if int64(len(body)) > f.Most {
|
||||
return nil, fmt.Errorf("%s is over the %d bytes a document may be (novox/hq ADR 0468 clause 6)", current, f.Most)
|
||||
}
|
||||
return &Fetched{Body: body, ContentType: resp.Header.Get("Content-Type")}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// checked resolves the host once, refuses it when any address it gives is refused, and answers the address to dial.
|
||||
func (f *Fetcher) checked(ctx context.Context, host string) (netip.Addr, error) {
|
||||
var addrs []netip.Addr
|
||||
if ip, err := netip.ParseAddr(host); err == nil {
|
||||
addrs = []netip.Addr{ip}
|
||||
} else {
|
||||
found, err := f.Resolve(ctx, host)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("the host %s cannot be resolved: %w", host, err)
|
||||
}
|
||||
addrs = found
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return netip.Addr{}, fmt.Errorf("the host %s resolves to no address", host)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if class := f.Refusal(a); class != "" {
|
||||
return netip.Addr{}, fmt.Errorf("refused: %s is %s, and the device process fetches nothing inside the mesh or the machine (novox/hq ADR 0468 clause 6)", a, class)
|
||||
}
|
||||
}
|
||||
return addrs[0].Unmap(), nil
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A fetch rig: names resolve as the test says, and every dial goes to the test's server whatever address was asked,
|
||||
// after the fetcher has checked the address it was about to dial and told the dial what that was.
|
||||
type fetchRig struct {
|
||||
f *Fetcher
|
||||
mu sync.Mutex
|
||||
dialed []string
|
||||
lookups map[string]int
|
||||
names map[string][]netip.Addr
|
||||
// after is what a host answers from its second lookup on, when it answers differently (a rebinding).
|
||||
after map[string][]netip.Addr
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newFetchRig(t *testing.T, h http.HandlerFunc) *fetchRig {
|
||||
t.Helper()
|
||||
r := &fetchRig{lookups: map[string]int{}, names: map[string][]netip.Addr{}, after: map[string][]netip.Addr{}}
|
||||
r.srv = httptest.NewServer(h)
|
||||
t.Cleanup(r.srv.Close)
|
||||
r.f = &Fetcher{MeshRange: netip.MustParsePrefix("10.10.0.0/24"), Timeout: 5 * time.Second, Most: 1 << 20, Redirects: FetchRedirects,
|
||||
Resolve: func(_ context.Context, host string) ([]netip.Addr, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.lookups[host]++
|
||||
a, ok := r.names[host]
|
||||
if !ok {
|
||||
return nil, errors.New("no such host")
|
||||
}
|
||||
if again, rebinds := r.after[host]; rebinds && r.lookups[host] > 1 {
|
||||
return again, nil
|
||||
}
|
||||
return a, nil
|
||||
},
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
r.mu.Lock()
|
||||
r.dialed = append(r.dialed, address)
|
||||
r.mu.Unlock()
|
||||
return (&net.Dialer{}).DialContext(ctx, network, r.srv.Listener.Addr().String())
|
||||
}}
|
||||
return r
|
||||
}
|
||||
|
||||
func addrs(s ...string) []netip.Addr {
|
||||
var out []netip.Addr
|
||||
for _, a := range s {
|
||||
out = append(out, netip.MustParseAddr(a))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1599/AC1: a url with a scheme other than http or https is refused by name.
|
||||
func TestAURLOfAnotherSchemeIsRefused(t *testing.T) {
|
||||
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) {})
|
||||
for _, u := range []string{"file:///etc/passwd", "ftp://example.com/a.pdf", "gopher://example.com/", "javascript:alert(1)", "//example.com/a.pdf"} {
|
||||
_, err := r.f.Fetch(t.Context(), u)
|
||||
if err == nil || (!strings.Contains(err.Error(), "only http and https") && !strings.Contains(err.Error(), "is not an address")) {
|
||||
t.Errorf("%s: error = %v", u, err)
|
||||
}
|
||||
if err := CheckURLScheme(u); err == nil {
|
||||
t.Errorf("%s: the tool side took it", u)
|
||||
}
|
||||
}
|
||||
if len(r.dialed) != 0 {
|
||||
t.Errorf("dialled %v", r.dialed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC2: a host that is, or resolves to, a loopback, private, link-local, shared, unspecified or multicast address,
|
||||
// or one in the mesh's range, is refused naming the class, and no connection is made.
|
||||
func TestAddressesInsideTheMeshAndTheMachineAreRefused(t *testing.T) {
|
||||
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) { t.Error("a request reached the server") })
|
||||
cases := map[string]string{
|
||||
"127.0.0.1": "loopback", "::1": "loopback", "10.0.0.1": "private address (RFC 1918)", "192.168.1.1": "private address (RFC 1918)",
|
||||
"172.16.0.1": "private address (RFC 1918)", "fd00::1": "private address (RFC 4193)", "169.254.1.1": "link-local", "fe80::1": "link-local",
|
||||
"100.64.0.1": "shared address (RFC 6598)", "0.0.0.0": "unspecified", "::": "unspecified", "224.0.0.1": "multicast", "ff02::1": "multicast",
|
||||
"10.10.0.7": "private address (RFC 1918)", "::ffff:127.0.0.1": "loopback",
|
||||
}
|
||||
for ip, class := range cases {
|
||||
host := ip
|
||||
if strings.Contains(ip, ":") {
|
||||
host = "[" + ip + "]"
|
||||
}
|
||||
_, err := r.f.Fetch(t.Context(), "http://"+host+"/a.pdf")
|
||||
if err == nil || !strings.Contains(err.Error(), class) {
|
||||
t.Errorf("%s: error = %v, want the class %q", ip, err, class)
|
||||
}
|
||||
// The same address behind a name.
|
||||
r.names["inside.test"] = addrs("203.0.113.9", ip)
|
||||
if _, err := r.f.Fetch(t.Context(), "http://inside.test/a.pdf"); err == nil || !strings.Contains(err.Error(), class) {
|
||||
t.Errorf("inside.test -> %s: error = %v", ip, err)
|
||||
}
|
||||
}
|
||||
// The mesh's range from fetch.json, when it is not a private range.
|
||||
r.f.MeshRange = netip.MustParsePrefix("203.0.113.0/24")
|
||||
if _, err := r.f.Fetch(t.Context(), "http://203.0.113.5/a.pdf"); err == nil || !strings.Contains(err.Error(), "the mesh's own network") {
|
||||
t.Errorf("the mesh range: error = %v", err)
|
||||
}
|
||||
if len(r.dialed) != 0 {
|
||||
t.Errorf("a connection was made to %v", r.dialed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC3: a redirect to such an address is refused; a sixth redirect is refused; a redirect to a public address is followed.
|
||||
func TestRedirectsAreCheckedAndBounded(t *testing.T) {
|
||||
hops := 0
|
||||
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/to-loopback":
|
||||
http.Redirect(w, req, "http://127.0.0.1/secret", http.StatusFound)
|
||||
case "/to-name":
|
||||
http.Redirect(w, req, "http://inside.test/x", http.StatusFound)
|
||||
case "/loop":
|
||||
hops++
|
||||
http.Redirect(w, req, "/loop", http.StatusFound)
|
||||
case "/ok":
|
||||
http.Redirect(w, req, "http://public.test/final.pdf", http.StatusMovedPermanently)
|
||||
default:
|
||||
w.Write([]byte("%PDF-1.7 final"))
|
||||
}
|
||||
})
|
||||
r.names["public.test"] = addrs("203.0.113.9")
|
||||
r.names["inside.test"] = addrs("192.168.0.9")
|
||||
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-loopback"); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Errorf("redirect to loopback: error = %v", err)
|
||||
}
|
||||
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-name"); err == nil || !strings.Contains(err.Error(), "private address (RFC 1918)") {
|
||||
t.Errorf("redirect to a name: error = %v", err)
|
||||
}
|
||||
if _, err := r.f.Fetch(t.Context(), "http://public.test/loop"); err == nil || !strings.Contains(err.Error(), "redirected more than 5 times") {
|
||||
t.Errorf("a loop: error = %v", err)
|
||||
}
|
||||
if hops != 6 {
|
||||
t.Errorf("the loop was followed %d times, want the first request and 5 redirects (6 requests)", hops)
|
||||
}
|
||||
got, err := r.f.Fetch(t.Context(), "http://public.test/ok")
|
||||
if err != nil || string(got.Body) != "%PDF-1.7 final" {
|
||||
t.Errorf("a redirect to a public address: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC4: a body over the limit and a fetch over the time are refused, and nothing is returned to be stored.
|
||||
func TestABodyOverTheLimitAndAFetchOverTheTimeAreRefused(t *testing.T) {
|
||||
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/big":
|
||||
w.Write([]byte(strings.Repeat("x", 2000)))
|
||||
case "/chunked":
|
||||
w.(http.Flusher).Flush() // no length is declared
|
||||
w.Write([]byte(strings.Repeat("x", 2000)))
|
||||
case "/slow":
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
w.Write([]byte("late"))
|
||||
}
|
||||
})
|
||||
r.names["public.test"] = addrs("203.0.113.9")
|
||||
r.f.Most = 1000
|
||||
for _, p := range []string{"/big", "/chunked"} {
|
||||
if got, err := r.f.Fetch(t.Context(), "http://public.test"+p); err == nil || got != nil || !strings.Contains(err.Error(), "over the") {
|
||||
t.Errorf("%s: %v, %v", p, got, err)
|
||||
}
|
||||
}
|
||||
r.f.Timeout = 100 * time.Millisecond
|
||||
if got, err := r.f.Fetch(t.Context(), "http://public.test/slow"); err == nil || got != nil {
|
||||
t.Errorf("a slow fetch: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC5: the connection goes to the address that was checked: a name that answers differently on a second lookup
|
||||
// is not dialled again, and each host is looked up once.
|
||||
func TestTheCheckedAddressIsTheOneDialled(t *testing.T) {
|
||||
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) { w.Write([]byte("%PDF-1.7 x")) })
|
||||
r.names["rebind.test"] = addrs("203.0.113.9")
|
||||
r.after["rebind.test"] = addrs("127.0.0.1") // a second lookup would say loopback
|
||||
if _, err := r.f.Fetch(t.Context(), "http://rebind.test:8080/a.pdf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.dialed) != 1 || r.dialed[0] != "203.0.113.9:8080" {
|
||||
t.Errorf("dialled %v, want the checked address and the url's port", r.dialed)
|
||||
}
|
||||
if r.lookups["rebind.test"] != 1 {
|
||||
t.Errorf("the host was looked up %d times", r.lookups["rebind.test"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC6: a fetched PDF is stored under its SHA-256 and printed as that object.
|
||||
func TestAFetchedPDFIsStoredUnderItsHashAndPrinted(t *testing.T) {
|
||||
fake := newIPPFake(t, 41, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := pdfOf(4000)
|
||||
web := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Write(doc)
|
||||
})
|
||||
web.names["public.test"] = addrs("203.0.113.9")
|
||||
r.worker.Fetcher = func() (*Fetcher, error) { return web.f, nil }
|
||||
got, err := r.call(t, "printer.print", map[string]any{"url": "http://public.test/letter.pdf"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
key := sha256Hex(doc)
|
||||
if m["object"] != key || m["job-id"] != float64(41) || m["format"] != "application/pdf" {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
if stored, err := r.objects.Get(t.Context(), key); err != nil || string(stored) != string(doc) {
|
||||
t.Errorf("stored under the hash: %v", err)
|
||||
}
|
||||
if _, sent := fake.request(t); string(sent) != string(doc) {
|
||||
t.Error("the printer was not sent the fetched document")
|
||||
}
|
||||
queued := r.bus.published[0]
|
||||
if queued.URL != "http://public.test/letter.pdf" || queued.Object != "" {
|
||||
t.Errorf("queued %+v", queued)
|
||||
}
|
||||
if err := CheckURLScheme("https://public.test/letter.pdf"); err != nil {
|
||||
t.Errorf("https refused: %v", err)
|
||||
}
|
||||
// A refusal inside the mesh is answered, and nothing is stored nor printed.
|
||||
fake.body = nil
|
||||
before := len(r.objects.keys())
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-shanks-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Errorf("a loopback address: error = %v", err)
|
||||
}
|
||||
if len(r.objects.keys()) != before || fake.body != nil {
|
||||
t.Error("something was stored or printed for a refused address")
|
||||
}
|
||||
}
|
||||
|
||||
// The fetch's facts are the file the mesh writes (files/fetch.json) from the `mesh-range` machine fact, and the device
|
||||
// process reads them.
|
||||
func TestTheFetchFactsAreTheMeshRange(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../files/fetch.json")
|
||||
if err != nil || !strings.Contains(string(raw), `"mesh-range": "${machine:mesh-range}"`) {
|
||||
t.Fatalf("files/fetch.json = %s, %v", raw, err)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "fetch.json")
|
||||
os.WriteFile(file, []byte(`{"mesh-range": "10.10.0.0/24"}`), 0o600)
|
||||
p, err := ReadFetchFacts(file)
|
||||
if err != nil || p.String() != "10.10.0.0/24" {
|
||||
t.Errorf("prefix %v, %v", p, err)
|
||||
}
|
||||
os.WriteFile(file, []byte(`{}`), 0o600)
|
||||
if _, err := ReadFetchFacts(file); err == nil {
|
||||
t.Error("facts with no range were taken")
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func TestAnOpenDocumentIsFoundAgainAfterARestartAndItsPagesAreDeletedWhenClosed(
|
||||
docExpiry = 200 * time.Millisecond
|
||||
t.Cleanup(func() { docExpiry = old })
|
||||
s := &Service{Settings: Settings{Scanner: f.base(), Folder: folder, Resolution: 200, ColorMode: "color"}, Now: func() time.Time { return fixedNow },
|
||||
Asks: askBus, AnswerWait: 5 * time.Second, ScanWait: 5 * time.Second, AnswerPoll: 5 * time.Millisecond, Jobs: NewJobs()}
|
||||
Asks: askBus, AnswerWait: 5 * time.Second, ScanWait: 5 * time.Second, AnswerPoll: 5 * time.Millisecond, Jobs: NewJobs(), Fetch: fetchClient, DocExpiry: docExpiry}
|
||||
if err := s.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -245,6 +245,39 @@ func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) {
|
||||
return ans.State()
|
||||
}
|
||||
|
||||
// formatAttributes is what the format check asks for: the document formats the printer takes (RFC 8011 §5.4.22).
|
||||
var formatAttributes = []string{"document-format-supported"}
|
||||
|
||||
// FormatsOf asks the printer, over IPP, which document formats it lists in document-format-supported.
|
||||
func FormatsOf(ctx context.Context, host string) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host),
|
||||
bytes.NewReader(EncodeGetPrinterAttributes(ippPrinterURI(host), formatAttributes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/ipp")
|
||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the printer's IPP service does not answer: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
|
||||
}
|
||||
ans, err := DecodeIPP(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ans.Status >= 0x0100 {
|
||||
return nil, fmt.Errorf("the printer refused the IPP request with status 0x%04x", ans.Status)
|
||||
}
|
||||
return ans.texts("document-format-supported"), nil
|
||||
}
|
||||
|
||||
// PrintOptions are what a Print-Job asks of the printer.
|
||||
type PrintOptions struct {
|
||||
Format string // document-format, application/pdf or image/jpeg
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// uploadVia is how the tool side asks a machine's node-files for an upload; a test puts its own in its place.
|
||||
var uploadVia = sdkUpload
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "device" {
|
||||
os.Exit(device())
|
||||
@@ -84,7 +87,7 @@ func askFrom(args map[string]any) (Ask, error) {
|
||||
var a Ask
|
||||
var err error
|
||||
for name, dst := range map[string]*string{"page": &a.Page, "object": &a.Object, "upload": &a.Upload, "sha256": &a.SHA256,
|
||||
"data": &a.Data, "sides": &a.Sides, "media": &a.Media} {
|
||||
"data": &a.Data, "sides": &a.Sides, "media": &a.Media, "path": &a.Path, "node": &a.Node, "url": &a.URL, "ask": &a.Of} {
|
||||
if *dst, err = strArg(args, name); err != nil {
|
||||
return a, err
|
||||
}
|
||||
@@ -109,7 +112,8 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
}
|
||||
return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Asks: askBus,
|
||||
AnswerWait: answerWait, ScanWait: scanWait, AnswerPoll: answerPoll,
|
||||
QuickWait: quickWait, FeederWait: feederWait, Jobs: jobs}, nil
|
||||
QuickWait: quickWait, FeederWait: feederWait, Jobs: jobs, Upload: uploadVia, Fetch: fetchClient, DocExpiry: docExpiry, DocRetry: docRetry,
|
||||
Bucket: func() (string, error) { return ReadBucket(os.Getenv("BROTHER_BUCKET")) }}, nil
|
||||
}
|
||||
// ask is a tool that reads its arguments, and answers what the service gives.
|
||||
ask := func(run func(s *Service, ctx context.Context, a Ask) (map[string]any, error), bound time.Duration) func(map[string]any) (any, error) {
|
||||
@@ -147,39 +151,37 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "printer.print",
|
||||
Description: "Print a document on the printer: a file under the home folder of the account the tool runs as, on the machine it runs on (path: reads it, uploads it in parts " +
|
||||
"and prints it, in one call), a scan kept in the bucket (object), or a completed upload (upload; for a module that sends " +
|
||||
"bytes over the bus). Only PDF and JPEG, told by the file's bytes and not its name. The device " +
|
||||
"process sends it as an IPP Print-Job in its own format and answers the job's id and state (with path, the upload id and the size).",
|
||||
Description: "Print a document on the printer, in one call: a key in the bucket (object: a scan, or a document stored there), a file on any " +
|
||||
"machine of the mesh (path with node: that machine's node-files sends it into the bucket, a place it keeps a secret is " +
|
||||
"refused there by name and nothing is printed), or a web link (url). Name exactly one. A Word, spreadsheet or slide " +
|
||||
"document is turned into a PDF first; otherwise only PDF and JPEG, told by the bytes and not the name, and only a format " +
|
||||
"the printer lists in document-format-supported. The device process sends it as an IPP Print-Job and answers the job's " +
|
||||
"id and state. No document crosses the bus. A call answers within 25 s (the tool runner cuts a call at 30 s): a print not done by then goes on and is answered as started with its ask id, and printer.print with only that ask reads it.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"path": str("an absolute path of a PDF or JPEG file under the tool account's home folder (symbolic links are resolved and the real path must lie there too; any other path is refused in one fixed way)"),
|
||||
"object": str("a scan's key, as scan.added names it"),
|
||||
"upload": str("a completed upload's id"),
|
||||
"object": str("a stored document's key: a scan's key, as scan.added names it, or the key node-files.upload answered"),
|
||||
"path": str("an absolute path of a file on the machine node names"),
|
||||
"node": str("the machine path is on, such as g14"),
|
||||
"url": str("an http or https address of a document or a page"),
|
||||
"ask": str("the ask id of an earlier print that was answered as started: alone, it reads that print's outcome"),
|
||||
"copies": num("1 to 99 (default 1)"),
|
||||
"sides": str("one-sided (default), two-sided-long-edge or two-sided-short-edge"),
|
||||
"media": str("the paper's size name (default " + DefaultMedia + ")"),
|
||||
}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
path, err := strArg(args, "path")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := askFrom(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Of != "" && len(args) > 1 {
|
||||
return nil, fmt.Errorf("ask is read on its own: give the ask id alone to read an earlier print")
|
||||
}
|
||||
s, err := service()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
return s.PrinterPrint(ctx, a)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
return s.PrintFile(ctx, path, a)
|
||||
return s.PrinterPrint(ctx, a)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Requires []string `json:"requires"`
|
||||
Requires []requirement `json:"requires"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Binds map[string]string `json:"binds"`
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
Emits []string `json:"emits"`
|
||||
@@ -28,6 +29,31 @@ type manifest struct {
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
// requirement is an entry of `requires`: a name, or an object with the name and the shares of the bucket.
|
||||
type requirement struct {
|
||||
Name string `json:"name"`
|
||||
Shares []struct {
|
||||
To string `json:"to"`
|
||||
Methods []string `json:"methods"`
|
||||
} `json:"shares"`
|
||||
}
|
||||
|
||||
func (r *requirement) UnmarshalJSON(b []byte) error {
|
||||
if err := json.Unmarshal(b, &r.Name); err == nil {
|
||||
return nil
|
||||
}
|
||||
type plain requirement
|
||||
return json.Unmarshal(b, (*plain)(r))
|
||||
}
|
||||
|
||||
func (m manifest) requireNames() []string {
|
||||
var out []string
|
||||
for _, r := range m.Requires {
|
||||
out = append(out, r.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type seat struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
@@ -265,6 +291,7 @@ func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) {
|
||||
"BROTHER_ADMIN_PASSWORD": m.OwnSecrets["admin-password"]["path"],
|
||||
"BROTHER_BROKER": m.OwnSecrets["broker-device"]["path"],
|
||||
"BROTHER_STORE": "${dir:state}/store.json",
|
||||
"BROTHER_FETCH": "${dir:state}/fetch.json", // 1599: the tunnel's range, which the fetch never reaches
|
||||
}
|
||||
if !reflect.DeepEqual(env, want) {
|
||||
t.Errorf("env = %v, want %v", env, want)
|
||||
@@ -330,8 +357,8 @@ func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) {
|
||||
// written into store.json, a file only the device account can read; the tools' bundle is not told where it is.
|
||||
func TestTheBucketIsRequiredAndItsKeyIsOnlyTheDeviceAccounts(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if !reflect.DeepEqual(m.Requires, []string{"s3-bucket"}) {
|
||||
t.Errorf("requires = %v", m.Requires)
|
||||
if !reflect.DeepEqual(m.requireNames(), []string{"s3-bucket"}) {
|
||||
t.Errorf("requires = %v", m.requireNames())
|
||||
}
|
||||
if m.Binds["s3-bucket"] != "${dir:state}/store-binding.json" || m.Secrets["s3-bucket"] != "${dir:state}/store.secret" {
|
||||
t.Errorf("binds %v, secrets %v", m.Binds, m.Secrets)
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
)
|
||||
|
||||
// Objects is the module's bucket.
|
||||
@@ -55,6 +57,13 @@ func Store(ctx context.Context, o Objects, key, mediaType string, data []byte) e
|
||||
return o.Put(ctx, key, mediaType, data)
|
||||
}
|
||||
|
||||
// Presigner is a store that signs a link to one of its objects (novox/hq ADR 0468 §3): the device process's, with the
|
||||
// binding's key, which reaches this module's bucket only.
|
||||
type Presigner interface {
|
||||
// Link is a link for GET or PUT of one key that lives for the given time (at most ten minutes).
|
||||
Link(method, key string, expires time.Duration) (string, error)
|
||||
}
|
||||
|
||||
// StoreFacts is the file the mesh writes from the s3-bucket binding and its secret.
|
||||
type StoreFacts struct {
|
||||
Scheme string `json:"scheme"`
|
||||
@@ -114,6 +123,16 @@ func NewS3(f StoreFacts) *S3 {
|
||||
|
||||
func (s *S3) Bucket() string { return s.Name }
|
||||
|
||||
// Link signs a link for one object of the bucket with the binding's key, for the host the binding gives.
|
||||
func (s *S3) Link(method, key string, expires time.Duration) (string, error) {
|
||||
now := time.Now
|
||||
if s.Now != nil {
|
||||
now = s.Now
|
||||
}
|
||||
return objectlink.Presign(objectlink.Request{Method: method, Endpoint: s.Endpoint, Bucket: s.Name, Key: key,
|
||||
AccessKey: s.Access, SecretKey: s.Secret, Region: s.Region, Expires: expires, Now: now()})
|
||||
}
|
||||
|
||||
func (s *S3) do(ctx context.Context, method, key, mediaType string, body []byte) (*http.Response, error) {
|
||||
u, err := url.Parse(s.Endpoint + "/" + s.Name + "/" + key)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -9,9 +10,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memObjects is the bucket in memory, for the tests of what the device process keeps.
|
||||
@@ -19,6 +22,10 @@ type memObjects struct {
|
||||
mu sync.Mutex
|
||||
kept map[string][]byte
|
||||
puts int
|
||||
// corrupt, when set, makes a GET of a link answer bytes that are not the kept ones.
|
||||
corrupt bool
|
||||
// links records each link signed: "<method> <key> <seconds>".
|
||||
links []string
|
||||
}
|
||||
|
||||
func newMemObjects() *memObjects { return &memObjects{kept: map[string][]byte{}} }
|
||||
@@ -68,6 +75,32 @@ func (m *memObjects) Delete(_ context.Context, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Link is a link the bucket in memory serves itself (RoundTrip): the key in the path and the life in the query, as a
|
||||
// presigned link says them (X-Amz-Expires).
|
||||
func (m *memObjects) Link(method, key string, expires time.Duration) (string, error) {
|
||||
m.mu.Lock()
|
||||
m.links = append(m.links, method+" "+key+" "+strconv.Itoa(int(expires/time.Second)))
|
||||
m.mu.Unlock()
|
||||
return "http://" + m.Bucket() + ".objects.test/" + key + "?X-Amz-Expires=" + strconv.Itoa(int(expires/time.Second)), nil
|
||||
}
|
||||
|
||||
// RoundTrip answers a GET of a link made by Link with the object kept under its key.
|
||||
func (m *memObjects) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
m.mu.Lock()
|
||||
b, ok := m.kept[strings.TrimPrefix(r.URL.Path, "/")]
|
||||
corrupt := m.corrupt
|
||||
m.mu.Unlock()
|
||||
if corrupt && ok {
|
||||
b = append(append([]byte(nil), b...), 0)
|
||||
}
|
||||
status := 200
|
||||
if !ok {
|
||||
status = 404
|
||||
}
|
||||
return &http.Response{StatusCode: status, Body: io.NopCloser(bytes.NewReader(b)), ContentLength: int64(len(b)),
|
||||
Header: http.Header{}, Request: r}, nil
|
||||
}
|
||||
|
||||
func (m *memObjects) keys() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -36,6 +36,11 @@ const (
|
||||
// DeviceProcessID is the device process's id as module.json's resource "device" names it: the controller
|
||||
// delivers its bus account as the own secret broker-device.
|
||||
DeviceProcessID = "device"
|
||||
// PrintTakeUp is how long a print ask may wait on the queue before the device process takes it up, and PrintFollow
|
||||
// how long the tool side follows it to its end: the device process may fetch and convert before the job. A call
|
||||
// answers within QuickWait whatever these are, and the print goes on (the tool runner cuts every call at 30 s).
|
||||
PrintTakeUp = 3 * time.Minute
|
||||
PrintFollow = 6 * time.Minute
|
||||
// AnswerWait is how long a verb waits for the device process; ScanWait how long a scan does.
|
||||
AnswerWait = 60 * time.Second
|
||||
ScanWait = 180 * time.Second
|
||||
@@ -88,6 +93,14 @@ type Ask struct {
|
||||
|
||||
Document string `json:"document,omitempty"` // scan: start, or an open document's id: a glass page into it
|
||||
Finish bool `json:"finish,omitempty"` // scan: close the document named in Document into one PDF
|
||||
|
||||
// Path with Node are what printer.print is asked for a file of a machine: the tool side turns them into an Object
|
||||
// before anything is queued, so the queued ask carries a key and the options and never these (ADR 0468 clause 5).
|
||||
// A URL is queued as given: the device process alone fetches it (clause 6).
|
||||
Path string `json:"-"` // print: a file on the machine Node
|
||||
Of string `json:"-"` // print: the ask id of an earlier print, whose outcome is read
|
||||
Node string `json:"-"` // print: the machine Path is on
|
||||
URL string `json:"url,omitempty"` // print: a web document the device process fetches (issue 1599)
|
||||
}
|
||||
|
||||
// AskBus is how the tools reach the device process: they queue an ask and read an answer back.
|
||||
@@ -145,7 +158,10 @@ func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[st
|
||||
// queue puts the ask on the work queue and answers its id, under which the answer is written. The asker waits
|
||||
// startBy for the device process to take the ask up (0 is for as long as it is waited for).
|
||||
func (s *Service) queue(ask Ask, startBy time.Duration) (string, error) {
|
||||
ask.ID, ask.At = newAskID(), s.Now().UTC()
|
||||
if ask.ID == "" {
|
||||
ask.ID = newAskID()
|
||||
}
|
||||
ask.At = s.Now().UTC()
|
||||
if startBy > 0 {
|
||||
ask.Deadline = ask.At.Add(startBy)
|
||||
}
|
||||
@@ -211,9 +227,12 @@ func validPage(page string) bool {
|
||||
|
||||
var (
|
||||
scanKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}\.pdf$`)
|
||||
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
|
||||
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
// printKeyPattern is a document the bucket keeps under its SHA-256: a scan's key, or the key node-files.upload
|
||||
// answers for a file sent from a machine (the bare SHA-256).
|
||||
printKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}(\.pdf)?$`)
|
||||
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
|
||||
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
)
|
||||
|
||||
// Sides are the values the printer's sides take.
|
||||
@@ -259,14 +278,23 @@ func checkUpload(a Ask) error {
|
||||
|
||||
// checkPrint is what is wrong with an ask to print, or nil, after the defaults are laid in.
|
||||
func checkPrint(a *Ask) error {
|
||||
if (a.Object == "") == (a.Upload == "") {
|
||||
return fmt.Errorf("name either object (a scan's key) or upload (a completed upload's id), not both and not neither")
|
||||
if a.Upload != "" {
|
||||
return fmt.Errorf("upload is no longer a source of printer.print: send the file with path and node, or name its key in object")
|
||||
}
|
||||
if a.Object != "" && !scanKeyPattern.MatchString(a.Object) {
|
||||
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
|
||||
if (a.Path == "") != (a.Node == "") {
|
||||
return fmt.Errorf("path and node go together: path is a file on the machine node names")
|
||||
}
|
||||
if a.Upload != "" && !uploadPattern.MatchString(a.Upload) {
|
||||
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
|
||||
sources := 0
|
||||
for _, given := range []bool{a.Object != "", a.Path != "", a.URL != ""} {
|
||||
if given {
|
||||
sources++
|
||||
}
|
||||
}
|
||||
if sources != 1 {
|
||||
return fmt.Errorf("name exactly one of object (a key in the bucket), path with node (a file on that machine) or url, not %s", map[bool]string{true: "none", false: "two or more"}[sources == 0])
|
||||
}
|
||||
if a.Object != "" && !printKeyPattern.MatchString(a.Object) {
|
||||
return fmt.Errorf("object %q is not a stored document's key: 64 hex digits, with .pdf for a scan", a.Object)
|
||||
}
|
||||
if a.Copies == 0 {
|
||||
a.Copies = 1
|
||||
@@ -307,50 +335,42 @@ func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, err
|
||||
return s.call(ctx, a, s.AnswerWait)
|
||||
}
|
||||
|
||||
// PrintFile prints a PDF or a JPEG that lies on the machine the tool runs on: it reads the file, uploads it in
|
||||
// parts under an upload id of its own and prints that, in one call (issue 1551). The type is read from the
|
||||
// file's first bytes, never its name; any other file is refused before anything is queued. The answer is the
|
||||
// print's, with the upload id and the file's size.
|
||||
func (s *Service) PrintFile(ctx context.Context, path string, a Ask) (map[string]any, error) {
|
||||
if a.Object != "" || a.Upload != "" {
|
||||
return nil, fmt.Errorf("name path, or object, or upload: one of the three")
|
||||
}
|
||||
// The path is checked first, and refused in one fixed way that tells nothing about the file.
|
||||
doc, err := ReadDocument(path, MostParts*PartSize)
|
||||
// sdkUpload asks node's node-files to send a file of that machine into the bucket and answers the key it was
|
||||
// stored under (issue 1597). The form is the seat's verb on one machine, `seat:<seat>.<verb>@<node>`.
|
||||
func sdkUpload(node, path, bucket string) (string, error) {
|
||||
raw, err := stdio.Ask("seat:node-files.upload@"+node, map[string]any{"path": path, "bucket": bucket})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
a.Upload = "p-" + newAskID()
|
||||
if err := checkPrint(&a); err != nil {
|
||||
return nil, err
|
||||
var ans struct {
|
||||
Key string `json:"key"`
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
}
|
||||
if s.Settings.Printer == "" {
|
||||
return nil, unsetError("printer")
|
||||
if err := json.Unmarshal(raw, &ans); err != nil {
|
||||
return "", fmt.Errorf("node-files' answer is not JSON: %w", err)
|
||||
}
|
||||
if _, _, err := SniffFormat(doc); err != nil {
|
||||
return nil, fmt.Errorf("file %s: %w", path, err)
|
||||
if ans.Key == "" && len(ans.Answer) > 0 {
|
||||
_ = json.Unmarshal(ans.Answer, &ans)
|
||||
}
|
||||
sum, n := sha256Hex(doc), partCount(len(doc))
|
||||
for i := 1; i <= n; i++ {
|
||||
part, _, _ := cutPartOf(doc, i, PartSize)
|
||||
up, err := s.PrinterUpload(ctx, Ask{Upload: a.Upload, Part: i, Parts: n, SHA256: sum, Data: base64.StdEncoding.EncodeToString(part)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("part %d of %d of %s could not be uploaded: %w", i, n, path, err)
|
||||
}
|
||||
if i == n && up["complete"] != true {
|
||||
return nil, fmt.Errorf("the upload of %s is not complete after its last part: %v", path, up)
|
||||
}
|
||||
if ans.Key == "" {
|
||||
return "", fmt.Errorf("node-files' answer names no key")
|
||||
}
|
||||
out, err := s.PrinterPrint(ctx, a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s is uploaded as %s but could not be printed: %w", path, a.Upload, err)
|
||||
}
|
||||
out["path"], out["upload"], out["bytes"] = path, a.Upload, len(doc)
|
||||
return out, nil
|
||||
return ans.Key, nil
|
||||
}
|
||||
|
||||
// PrinterPrint asks the device process to print a kept scan or a completed upload.
|
||||
// PrinterPrint prints a stored document, a file of a machine, or a web link, in one call. A file of a machine is first
|
||||
// sent into the bucket by that machine's node-files (refused there by name when it is a secret, and then nothing is
|
||||
// printed); the print ask the device process takes carries the key and the options and no bytes.
|
||||
//
|
||||
// The tool runner cuts every call at 30 s, so the call answers within QuickWait, upload included. A print not done by
|
||||
// then goes on in the background and is answered as started, with its ask id; printer.print {ask} reads its outcome.
|
||||
func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, error) {
|
||||
if a.Of != "" {
|
||||
if a.Object != "" || a.Path != "" || a.Node != "" || a.URL != "" || a.Upload != "" {
|
||||
return nil, fmt.Errorf("ask is read on its own: name exactly one of object, path with node, url, or the ask id of an earlier print")
|
||||
}
|
||||
return s.PrintResult(ctx, a.Of)
|
||||
}
|
||||
a.Op = OpPrint
|
||||
if err := checkPrint(&a); err != nil {
|
||||
return nil, err
|
||||
@@ -358,9 +378,103 @@ func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, erro
|
||||
if s.Settings.Printer == "" {
|
||||
return nil, unsetError("printer")
|
||||
}
|
||||
return s.call(ctx, a, s.AnswerWait)
|
||||
bucket := ""
|
||||
switch {
|
||||
case a.URL != "":
|
||||
if err := CheckURLScheme(a.URL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case a.Path != "":
|
||||
if s.Upload == nil {
|
||||
return nil, fmt.Errorf("this module has no way to ask node-files for an upload")
|
||||
}
|
||||
var err error
|
||||
if bucket, err = s.Bucket(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
id := newAskID()
|
||||
j := s.Jobs.put(id)
|
||||
go func() {
|
||||
j.body, j.err = s.printRun(id, a, bucket)
|
||||
close(j.done)
|
||||
}()
|
||||
return s.printWait(ctx, id, j)
|
||||
}
|
||||
|
||||
// printRun is a print followed to its end, however long the caller waits: the upload, the ask, the device's answer.
|
||||
func (s *Service) printRun(id string, a Ask, bucket string) (map[string]any, error) {
|
||||
from := map[string]any{}
|
||||
if a.Path != "" {
|
||||
key, err := s.Upload(a.Node, a.Path, bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s on %s could not be sent to the bucket, and nothing is printed: %w", a.Path, a.Node, err)
|
||||
}
|
||||
from["path"], from["node"] = a.Path, a.Node
|
||||
a.Object, a.Path, a.Node = key, "", ""
|
||||
}
|
||||
a.ID = id
|
||||
if _, err := s.queue(a, PrintTakeUp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), PrintFollow)
|
||||
defer cancel()
|
||||
out, err := s.await(ctx, id, PrintFollow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range from {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// printWait gives the print's outcome if it comes within QuickWait, and started with the ask id otherwise.
|
||||
func (s *Service) printWait(ctx context.Context, id string, j *job) (map[string]any, error) {
|
||||
quick := s.QuickWait
|
||||
if quick <= 0 {
|
||||
quick = QuickWait
|
||||
}
|
||||
select {
|
||||
case <-j.done:
|
||||
return j.body, j.err
|
||||
case <-time.After(quick):
|
||||
return map[string]any{"state": "started", "ask": id,
|
||||
"note": "the print goes on; read its outcome with printer.print {ask}"}, nil
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("the caller went away; the print %s goes on: %w", id, ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// PrintResult is the outcome of the print with this ask id: the device's answer when it is done (a failure is the
|
||||
// error), and started, still printing, while it is not. A print this process followed is read from it, one it did not
|
||||
// (it was restarted) from the device's answers, which are kept for 10 minutes.
|
||||
func (s *Service) PrintResult(ctx context.Context, id string) (map[string]any, error) {
|
||||
if !askIDPattern.MatchString(id) {
|
||||
return nil, fmt.Errorf("ask %q is not an ask id: the 24 hex digits printer.print answered", id)
|
||||
}
|
||||
if j := s.Jobs.get(id); j != nil {
|
||||
return s.printWait(ctx, id, j)
|
||||
}
|
||||
raw, err := s.Asks.Answer(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the device process's answers cannot be read: %w", err)
|
||||
}
|
||||
if raw == nil {
|
||||
return map[string]any{"state": "started", "ask": id, "note": "still printing, or this ask is not known"}, nil
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("the device process's answer is not JSON: %w", err)
|
||||
}
|
||||
if msg, bad := out["error"].(string); bad {
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var askIDPattern = regexp.MustCompile(`^[0-9a-f]{24}$`)
|
||||
|
||||
// PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login
|
||||
// (ADR 0467 clause 5). A part that does not answer is an answer, with why, not an error. Which of printer and
|
||||
// scanner is not set is said by name, and nothing is contacted.
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// uploadFake stands for node-files.upload on another machine: it records the ask and answers a key, or refuses.
|
||||
type uploadFake struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
key string
|
||||
err error
|
||||
}
|
||||
|
||||
// withUpload puts a fake node-files in the tool side's place, and the bucket file the mesh writes from the binding
|
||||
// (files/bucket.json) where the tool reads it.
|
||||
// seen is the uploads asked so far, read under the fake's lock (the print runs in the background).
|
||||
func (f *uploadFake) seen() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.calls...)
|
||||
}
|
||||
|
||||
func withUpload(t *testing.T, key string, err error) *uploadFake {
|
||||
t.Helper()
|
||||
f := &uploadFake{key: key, err: err}
|
||||
old := uploadVia
|
||||
uploadVia = func(node, path, bucket string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, node+"|"+path+"|"+bucket)
|
||||
return f.key, f.err
|
||||
}
|
||||
t.Cleanup(func() { uploadVia = old })
|
||||
file := filepath.Join(t.TempDir(), "bucket.json")
|
||||
if err := os.WriteFile(file, []byte(`{"bucket": "brother-test"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("BROTHER_BUCKET", file)
|
||||
return f
|
||||
}
|
||||
|
||||
// 1598/AC1: none, or two, of object, path+node and url is refused naming the rule; path without node is refused.
|
||||
func TestPrintTakesExactlyOneSource(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
up := withUpload(t, "k", nil)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
key := sha256Hex([]byte("x")) + ".pdf"
|
||||
for name, args := range map[string]map[string]any{
|
||||
"none": {},
|
||||
"only options": {"copies": float64(2)},
|
||||
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "g14"},
|
||||
"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"},
|
||||
"all three": {"object": key, "path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
|
||||
"path without node": {"path": "/home/ops/a.pdf"},
|
||||
"node without path": {"node": "g14", "object": key},
|
||||
"the retired upload": {"upload": "u1"},
|
||||
"object and upload": {"object": key, "upload": "u1"},
|
||||
"path and its object": {"path": "/home/ops/a.pdf", "node": "g14", "object": key},
|
||||
} {
|
||||
_, err := r.call(t, "printer.print", args)
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
continue
|
||||
}
|
||||
rule := strings.Contains(err.Error(), "exactly one of object") || strings.Contains(err.Error(), "path and node go together") ||
|
||||
strings.Contains(err.Error(), "upload is no longer")
|
||||
if !rule {
|
||||
t.Errorf("%s: the refusal does not name the rule: %v", name, err)
|
||||
}
|
||||
}
|
||||
if r.bus.asks() != 0 || len(up.seen()) != 0 || fake.body != nil {
|
||||
t.Errorf("%d asks queued, %d uploads asked, printer sent %d bytes", r.bus.asks(), len(up.seen()), len(fake.body))
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC2: with path and node g14, printer.print asks g14's node-files to upload the path into brother's bucket, and
|
||||
// queues a print ask that carries the returned key and the options: under 4 KiB, and no path, node or bytes.
|
||||
func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
|
||||
fake := newIPPFake(t, 31, 5)
|
||||
doc := pdfOf(6000)
|
||||
key := sha256Hex(doc)
|
||||
up := withUpload(t, key, nil)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc) // what g14's upload stored
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14", "copies": float64(2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(up.seen()) != 1 || up.seen()[0] != "g14|/home/ops/letter.pdf|brother-test" {
|
||||
t.Fatalf("uploads asked: %v", up.seen())
|
||||
}
|
||||
if len(r.bus.published) != 1 {
|
||||
t.Fatalf("%d asks queued", len(r.bus.published))
|
||||
}
|
||||
queued := r.bus.published[0]
|
||||
raw, _ := json.Marshal(queued)
|
||||
var fields map[string]any
|
||||
json.Unmarshal(raw, &fields)
|
||||
if queued.Op != OpPrint || queued.Object != key || queued.Copies != 2 || len(raw) >= 4096 {
|
||||
t.Errorf("queued %s", raw)
|
||||
}
|
||||
for _, banned := range []string{"path", "node", "url", "data"} {
|
||||
if _, has := fields[banned]; has {
|
||||
t.Errorf("the queued ask carries %q: %s", banned, raw)
|
||||
}
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["job-id"] != float64(31) || m["node"] != "g14" || m["path"] != "/home/ops/letter.pdf" {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
if _, sent := fake.request(t); string(sent) != string(doc) {
|
||||
t.Errorf("the printer was not sent the stored document")
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC3: an upload refused by node-files is answered with node-files' reason, and nothing is printed.
|
||||
func TestARefusedUploadIsAnsweredWithItsReasonAndNothingIsPrinted(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
up := withUpload(t, "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name: it is in .ssh, which holds an account's credentials"))
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
_, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
|
||||
if err == nil || !strings.Contains(err.Error(), "is refused by name: it is in .ssh") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(up.seen()) != 1 || r.bus.asks() != 0 || fake.body != nil {
|
||||
t.Errorf("%d uploads, %d asks queued, printer sent %d bytes", len(up.seen()), r.bus.asks(), len(fake.body))
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC4: the device reads document-format-supported before the job and refuses a format the printer does not
|
||||
// list, naming the list; a printer that cannot be asked is sent nothing.
|
||||
func TestTheDeviceChecksTheFormatsThePrinterListsBeforeTheJob(t *testing.T) {
|
||||
fake := newIPPFake(t, 5, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := pdfOf(300)
|
||||
key := sha256Hex(doc) + ".pdf"
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.asked != 1 || fake.body == nil {
|
||||
t.Fatalf("the formats were asked %d times, the job was sent: %v", fake.asked, fake.body != nil)
|
||||
}
|
||||
|
||||
fake.body, fake.formats = nil, []string{"image/urf", "image/jpeg"}
|
||||
_, err := r.call(t, "printer.print", map[string]any{"object": key})
|
||||
if err == nil || !strings.Contains(err.Error(), "application/pdf") || !strings.Contains(err.Error(), "image/urf, image/jpeg") {
|
||||
t.Fatalf("a format not listed: error = %v", err)
|
||||
}
|
||||
if fake.body != nil {
|
||||
t.Error("a job was sent in a format the printer does not list")
|
||||
}
|
||||
|
||||
fake.formats = nil
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || fake.body != nil {
|
||||
t.Errorf("a printer that lists nothing: error = %v, sent %v", err, fake.body != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC5: brother's s3-bucket requirement shares put with seat:node-files; brother invokes seat:node-files.upload;
|
||||
// the bucket's name reaches the tool side through a file the mesh writes from the binding.
|
||||
func TestManifestSharesPutWithNodeFilesAndInvokesItsUpload(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Requires) != 1 || m.Requires[0].Name != "s3-bucket" || len(m.Requires[0].Shares) != 1 ||
|
||||
m.Requires[0].Shares[0].To != "seat:node-files" || strings.Join(m.Requires[0].Shares[0].Methods, ",") != "put" {
|
||||
t.Errorf("requires = %+v", m.Requires)
|
||||
}
|
||||
if len(m.Invokes) != 1 || m.Invokes[0] != "seat:node-files.upload" {
|
||||
t.Errorf("invokes = %v", m.Invokes)
|
||||
}
|
||||
var bucket map[string]any
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == "bucket" {
|
||||
bucket = r
|
||||
}
|
||||
}
|
||||
if bucket == nil || bucket["path"] != "${dir:state}/bucket.json" || bucket["template"] != "files/bucket.json" {
|
||||
t.Fatalf("bucket resource = %v", bucket)
|
||||
}
|
||||
raw, _ := os.ReadFile("../../files/bucket.json")
|
||||
if !strings.Contains(string(raw), "${bound:s3-bucket:bucket}") || strings.Contains(string(raw), "secret") {
|
||||
t.Errorf("files/bucket.json = %s", raw)
|
||||
}
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["name"] == "tools" && a["env"].(map[string]any)["BROTHER_BUCKET"] != "${dir:state}/bucket.json" {
|
||||
t.Errorf("the tools bundle is not given BROTHER_BUCKET: %v", a["env"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quickly makes a call wait this long before it answers started, for the test.
|
||||
func quickly(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
old := quickWait
|
||||
quickWait = d
|
||||
t.Cleanup(func() { quickWait = old })
|
||||
}
|
||||
|
||||
// 1598/AC7: a device answer slower than the call's bound is answered as started with the ask id within the bound, and
|
||||
// printer.print {ask} then returns the device's answer.
|
||||
func TestASlowPrintIsAnsweredAsStartedAndReadWithItsAsk(t *testing.T) {
|
||||
fake := newIPPFake(t, 71, 5)
|
||||
fake.delay = 400 * time.Millisecond
|
||||
quickly(t, 60*time.Millisecond)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := pdfOf(300)
|
||||
key := sha256Hex(doc) + ".pdf"
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
||||
began := time.Now()
|
||||
got, err := r.call(t, "printer.print", map[string]any{"object": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
id, _ := m["ask"].(string)
|
||||
if m["state"] != "started" || id == "" || time.Since(began) > 300*time.Millisecond {
|
||||
t.Fatalf("answer %v after %s", m, time.Since(began))
|
||||
}
|
||||
if QuickWait != 25*time.Second {
|
||||
t.Errorf("a call waits %s, want 25s", QuickWait)
|
||||
}
|
||||
// Read while it goes on, and after: the same id.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
read, err := r.call(t, "printer.print", map[string]any{"ask": id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rm := read.(map[string]any)
|
||||
if rm["state"] == nil {
|
||||
if rm["job-id"] != float64(71) || rm["object"] != key {
|
||||
t.Errorf("the outcome = %v", rm)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("never done: %v", rm)
|
||||
}
|
||||
}
|
||||
// A restarted tool process has no job: the device's answers are read.
|
||||
r2 := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
r2.answers.m = map[string]json.RawMessage{"0123456789abcdef01234567": json.RawMessage(`{"job-id": 5}`)}
|
||||
if read, err := r2.call(t, "printer.print", map[string]any{"ask": "0123456789abcdef01234567"}); err != nil || read.(map[string]any)["job-id"] != float64(5) {
|
||||
t.Errorf("read from the answers: %v, %v", read, err)
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"ask": id, "copies": float64(2)}); err == nil {
|
||||
t.Error("ask with another argument was accepted")
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"ask": "../x"}); err == nil {
|
||||
t.Error("a made-up ask id was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC8: printer.print {path, node} whose upload and print together pass the bound answers started with the ask id,
|
||||
// never a timeout, and the print follows.
|
||||
func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
|
||||
fake := newIPPFake(t, 72, 5)
|
||||
fake.delay = 100 * time.Millisecond
|
||||
quickly(t, 60*time.Millisecond)
|
||||
doc := pdfOf(300)
|
||||
key := sha256Hex(doc)
|
||||
up := withUpload(t, key, nil)
|
||||
old := uploadVia
|
||||
uploadVia = func(node, path, bucket string) (string, error) {
|
||||
time.Sleep(150 * time.Millisecond) // node-files is slow
|
||||
return old(node, path, bucket)
|
||||
}
|
||||
t.Cleanup(func() { uploadVia = old })
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14"})
|
||||
if err != nil {
|
||||
t.Fatalf("a slow upload failed the call: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
id, _ := m["ask"].(string)
|
||||
if m["state"] != "started" || id == "" || len(up.seen()) > 1 {
|
||||
t.Fatalf("answer %v", m)
|
||||
}
|
||||
for deadline := time.Now().Add(5 * time.Second); ; {
|
||||
read, err := r.call(t, "printer.print", map[string]any{"ask": id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rm := read.(map[string]any); rm["state"] == nil {
|
||||
if rm["job-id"] != float64(72) || rm["path"] != "/home/ops/letter.pdf" || rm["node"] != "g14" {
|
||||
t.Errorf("the outcome = %v", rm)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("never done")
|
||||
}
|
||||
}
|
||||
if len(up.seen()) != 1 || len(r.bus.published) != 1 || r.bus.published[0].Object != key {
|
||||
t.Errorf("uploads %v, asks %v", up.seen(), r.bus.published)
|
||||
}
|
||||
// An upload that fails after the call answered is the outcome, read with the ask.
|
||||
uploadVia = func(node, path, bucket string) (string, error) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
return "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name")
|
||||
}
|
||||
got, _ = r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
|
||||
id2, _ := got.(map[string]any)["ask"].(string)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"ask": id2}); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
t.Errorf("the failed upload's outcome: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -144,7 +145,12 @@ func newRig(t *testing.T, settings, password string) *printerRig {
|
||||
bus := &fakeAskBus{worker: w, answers: answers}
|
||||
old, oldWait, oldScan, oldPoll := askBus, answerWait, scanWait, answerPoll
|
||||
askBus, answerWait, scanWait, answerPoll = bus, 5*time.Second, 5*time.Second, 5*time.Millisecond
|
||||
t.Cleanup(func() { askBus, answerWait, scanWait, answerPoll = old, oldWait, oldScan, oldPoll })
|
||||
oldFetch := fetchClient
|
||||
fetchClient = &http.Client{Transport: objects}
|
||||
t.Cleanup(func() {
|
||||
askBus, answerWait, scanWait, answerPoll = old, oldWait, oldScan, oldPoll
|
||||
fetchClient = oldFetch
|
||||
})
|
||||
tl := tools(file, func() time.Time { return fixedNow })
|
||||
return &printerRig{tl: tl, run: tool(t, tl, "printer.settings").Run, bus: bus, worker: w, answers: answers, objects: objects, said: &out, mu: &mu}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
)
|
||||
|
||||
// EventAdded is the module's event: a scan was kept in the bucket (ADR 0464, ADR 0468 clause 2), said by the device
|
||||
@@ -29,6 +32,15 @@ type Service struct {
|
||||
QuickWait, FeederWait time.Duration
|
||||
// Jobs are the scans this process follows.
|
||||
Jobs *Jobs
|
||||
// Upload asks a machine's node-files to send one of its files into the bucket and answers the key; Bucket is
|
||||
// brother's bucket. Both are the tool side's (printer.print with path and node).
|
||||
// Fetch reads the links the device process signs; DocExpiry and DocRetry are the open documents' waits. They are
|
||||
// copied into the Service when it is made, because background work reads them while a test (or a reload) changes the
|
||||
// package's own, which would race.
|
||||
Fetch *http.Client
|
||||
DocExpiry, DocRetry time.Duration
|
||||
Upload func(node, path, bucket string) (string, error)
|
||||
Bucket func() (string, error)
|
||||
}
|
||||
|
||||
// Jobs are the scans this tool process has started and not yet been asked the outcome of. A scan runs in the
|
||||
@@ -229,7 +241,7 @@ func (s *Service) finishScan(ctx context.Context, id string, wait time.Duration,
|
||||
}
|
||||
if doc, _ := ans["document"].(string); doc != "" && ans["key"] == nil {
|
||||
// A page was added to an open document: nothing to file yet, and the document is closed if it is left open.
|
||||
s.armDocument(doc, docExpiry, 0)
|
||||
s.armDocument(doc, s.docExpiry(), 0)
|
||||
return ans, nil
|
||||
}
|
||||
if ask.Document != "" && ask.Finish {
|
||||
@@ -280,26 +292,19 @@ func (s *Service) fileAnswer(ctx context.Context, ans map[string]any) (map[strin
|
||||
func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]any, error) {
|
||||
key, _ := ans["key"].(string)
|
||||
sum, _ := ans["sha256"].(string)
|
||||
parts, _ := ans["parts"].(float64)
|
||||
if key == "" || sum == "" || parts < 1 || parts > MostAnswerParts {
|
||||
return nil, fmt.Errorf("the device process's answer to the scan names no stored scan: %v", ans)
|
||||
link, _ := ans["link"].(string)
|
||||
if key == "" || sum == "" || link == "" {
|
||||
return nil, fmt.Errorf("the device process's answer to the scan names no stored scan or no link to it: %v", withoutLink(ans))
|
||||
}
|
||||
got := make([][]byte, 0, int(parts))
|
||||
for i := 1; i <= int(parts); i++ {
|
||||
p, err := s.fetchPart(ctx, key, i)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
// A part fetch is a read, safe to repeat: one retry rides out a short bus outage (issue 1536).
|
||||
p, err = s.fetchPart(ctx, key, i)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s, but its part %d could not be fetched: %w", key, i, err)
|
||||
}
|
||||
got = append(got, p)
|
||||
}
|
||||
pdf, err := joinParts(got, sum)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s: %w", key, err)
|
||||
// The scan is read from the store by the link the device process signed, never through the bus (ADR 0468 §4), and
|
||||
// must hash to the scan's SHA-256 before anything is written.
|
||||
var buf bytes.Buffer
|
||||
gctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := objectlink.Get(gctx, s.fetchClient(), link, &buf, objectlink.MaxDocument, sum); err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s, but it could not be read from the store: %w", key, err)
|
||||
}
|
||||
pdf := buf.Bytes()
|
||||
at, err := time.Parse(time.RFC3339, fmt.Sprint(ans["scanned-at"]))
|
||||
if err != nil {
|
||||
at = s.Now()
|
||||
@@ -310,7 +315,7 @@ func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]a
|
||||
}
|
||||
body := map[string]any{}
|
||||
for k, v := range ans {
|
||||
if k != "key" && k != "size" && k != "parts" {
|
||||
if k != "key" && k != "size" && k != "link" && k != "link-seconds" {
|
||||
body[k] = v
|
||||
}
|
||||
}
|
||||
@@ -318,20 +323,41 @@ func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]a
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// fetchPart is one part of a stored object, asked of the device process and decoded.
|
||||
func (s *Service) fetchPart(ctx context.Context, object string, part int) ([]byte, error) {
|
||||
ans, err := s.call(ctx, Ask{Op: OpDocument, Object: object, Part: part}, s.AnswerWait)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// withoutLink is an answer said in an error without its link, which carries a signature.
|
||||
func withoutLink(ans map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range ans {
|
||||
if k != "link" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
data, _ := ans["data"].(string)
|
||||
raw, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the part is not base64: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) fetchClient() *http.Client {
|
||||
if s.Fetch != nil {
|
||||
return s.Fetch
|
||||
}
|
||||
return &http.Client{}
|
||||
}
|
||||
|
||||
func (s *Service) docExpiry() time.Duration {
|
||||
if s.DocExpiry > 0 {
|
||||
return s.DocExpiry
|
||||
}
|
||||
return 30 * time.Minute
|
||||
}
|
||||
|
||||
func (s *Service) docRetry() time.Duration {
|
||||
if s.DocRetry > 0 {
|
||||
return s.DocRetry
|
||||
}
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
// fetchClientDefault is what a Service made by the tools reads the links with; a test puts a client of its own in its place before it makes the tools.
|
||||
var fetchClient = &http.Client{}
|
||||
|
||||
// ScanStatus says whether the scanner answers, and what it is doing. A scanner that does not answer is an
|
||||
// answer, not an error: that is how the module's health is read.
|
||||
func (s *Service) ScanStatus(ctx context.Context) (map[string]any, error) {
|
||||
@@ -370,7 +396,7 @@ func (s *Service) armDocument(doc string, d time.Duration, attempts int) {
|
||||
if err == nil || attempts >= 10 || strings.Contains(err.Error(), "is closed already") || strings.Contains(err.Error(), "its id is wrong") {
|
||||
return
|
||||
}
|
||||
s.armDocument(doc, docRetry, attempts+1)
|
||||
s.armDocument(doc, s.docRetry(), attempts+1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -388,9 +414,9 @@ func (s *Service) Recover(ctx context.Context) error {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
left := docExpiry
|
||||
left := s.docExpiry()
|
||||
if at, err := time.Parse(time.RFC3339, fmt.Sprint(m["updated"])); err == nil {
|
||||
left = max(docExpiry-s.Now().Sub(at), time.Second)
|
||||
left = max(s.docExpiry()-s.Now().Sub(at), time.Second)
|
||||
}
|
||||
s.armDocument(id, left, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 1601/AC1: scanner.scan reads the stored PDF by the GET link the device's answer carries (never by parts) and writes it
|
||||
// into the scan folder; the answer is the event's body plus the path.
|
||||
func TestTheScanIsReadFromTheStoreByTheLinkAndFiled(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, folder := scanRig(t, f, Settings{Crop: false})
|
||||
body, err := r.scan(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*r.said) != 1 {
|
||||
t.Fatalf("%d events", len(*r.said))
|
||||
}
|
||||
stored, err := r.objects.Get(t.Context(), (*r.said)[0].body["object"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filed, err := os.ReadFile(body["path"].(string))
|
||||
if err != nil || string(filed) != string(stored) {
|
||||
t.Fatalf("the filed scan is not the stored one: %v", err)
|
||||
}
|
||||
if filepath.Dir(body["path"].(string)) != folder {
|
||||
t.Errorf("filed in %s, want %s", filepath.Dir(body["path"].(string)), folder)
|
||||
}
|
||||
for _, a := range r.bus.published {
|
||||
if a.Op == OpDocument {
|
||||
t.Errorf("a part of the scan was asked of the device process over the bus")
|
||||
}
|
||||
}
|
||||
for k := range (*r.said)[0].body {
|
||||
if body[k] == nil && k != "path" {
|
||||
t.Errorf("the event's %s is not in the answer", k)
|
||||
}
|
||||
}
|
||||
if body["link"] != nil || body["parts"] != nil || body["key"] != nil {
|
||||
t.Errorf("the answer carries the transport's own fields: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1601/AC2: the device's answer carries a link that expires within ten minutes and no bytes of the document; the real
|
||||
// store signs it with the binding's key for the host the binding gives.
|
||||
func TestTheDeviceAnswerCarriesALinkAndNoBytes(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: false})
|
||||
if _, err := r.scan(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ans map[string]any
|
||||
for _, raw := range r.answers.m {
|
||||
json.Unmarshal(raw, &ans)
|
||||
}
|
||||
link, _ := ans["link"].(string)
|
||||
u, err := url.Parse(link)
|
||||
if err != nil || link == "" {
|
||||
t.Fatalf("answer %v", ans)
|
||||
}
|
||||
secs, _ := strconv.Atoi(u.Query().Get("X-Amz-Expires"))
|
||||
if secs < 1 || time.Duration(secs)*time.Second > 10*time.Minute {
|
||||
t.Errorf("the link lives %d s", secs)
|
||||
}
|
||||
raw, _ := json.Marshal(ans)
|
||||
if ans["data"] != nil || len(raw) > 4096 {
|
||||
t.Errorf("the answer is %d bytes and carries data %v", len(raw), ans["data"] != nil)
|
||||
}
|
||||
|
||||
// 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",
|
||||
Key: "binding-key", Secret: "binding-secret"})
|
||||
s3.Now = func() time.Time { return fixedNow }
|
||||
got, err := s3.Link("GET", "abc.pdf", 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gu, _ := url.Parse(got)
|
||||
if gu.Host != "novox.internal:20001" || gu.Path != "/mesh-shanks-brother/abc.pdf" ||
|
||||
!strings.HasPrefix(gu.Query().Get("X-Amz-Credential"), "binding-key/") || gu.Query().Get("X-Amz-Expires") != "600" {
|
||||
t.Errorf("link %s", got)
|
||||
}
|
||||
if _, err := s3.Link("GET", "abc.pdf", 11*time.Minute); err == nil {
|
||||
t.Error("a link over ten minutes was signed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1601/AC3: a body whose SHA-256 differs from the scan's writes no file and answers the mismatch.
|
||||
func TestABodyOfAnotherHashWritesNoFile(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, folder := scanRig(t, f, Settings{Crop: false})
|
||||
r.objects.corrupt = true
|
||||
_, err := r.scan(t)
|
||||
if err == nil || !strings.Contains(err.Error(), "hashes to") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if e, _ := os.ReadDir(folder); len(e) != 0 {
|
||||
t.Errorf("a scan of the wrong hash was filed: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 1601/AC4: brother.scan.added is said before the file is written, once the scan is stored, and is under 4 KiB.
|
||||
func TestTheEventIsSaidBeforeTheFileIsWritten(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, folder := scanRig(t, f, Settings{Crop: false})
|
||||
var filedWhenSaid = -1
|
||||
emit := r.worker.Emit
|
||||
r.worker.Emit = func(body map[string]any, id string) error {
|
||||
e, _ := os.ReadDir(folder)
|
||||
filedWhenSaid = len(e)
|
||||
return emit(body, id)
|
||||
}
|
||||
if _, err := r.scan(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filedWhenSaid != 0 {
|
||||
t.Errorf("%d files in the folder when the event was said, want 0", filedWhenSaid)
|
||||
}
|
||||
said := (*r.said)[0]
|
||||
if !said.seen {
|
||||
t.Error("the event was said before the scan was stored")
|
||||
}
|
||||
raw, _ := json.Marshal(said.body)
|
||||
if len(raw) >= 4096 || said.body["path"] != nil {
|
||||
t.Errorf("the event is %d bytes, path %v", len(raw), said.body["path"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1601/AC5: no code of the device process writes a file outside its state directory: the only writes to the disk in the
|
||||
// module's code are the filing of a scan (file.go), made by the tool side (scan.go).
|
||||
func TestOnlyTheFilingWritesFiles(t *testing.T) {
|
||||
writes := []string{"os.WriteFile", "os.Create(", "os.OpenFile", "os.MkdirAll", "os.Mkdir(", "os.Rename", "os.CreateTemp", "File(s.Settings.Folder"}
|
||||
files, _ := filepath.Glob("*.go")
|
||||
for _, name := range files {
|
||||
if strings.HasSuffix(name, "_test.go") || name == "file.go" {
|
||||
continue
|
||||
}
|
||||
raw, _ := os.ReadFile(name)
|
||||
for _, w := range writes {
|
||||
if strings.Contains(string(raw), w) && !(name == "scan.go" && w == "File(s.Settings.Folder") {
|
||||
t.Errorf("%s writes with %s: only file.go does, and scan.go (the tool side) calls it", name, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, _ := os.ReadFile("deviceproc.go")
|
||||
if strings.Contains(string(raw), "File(") {
|
||||
t.Error("deviceproc.go files a scan")
|
||||
}
|
||||
}
|
||||
@@ -362,38 +362,3 @@ func (b *flakyBus) Publish(id string, body any) error {
|
||||
}
|
||||
return b.fakeAskBus.Publish(id, body)
|
||||
}
|
||||
|
||||
// 1536: a part fetch that fails once is asked again, and the scan is filed; one that fails twice is a failure
|
||||
// naming the stored object, so it can be filed later.
|
||||
func TestAFailedPartFetchIsAskedOnceMoreBeforeTheScanFails(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: false})
|
||||
r.bus = nil
|
||||
flaky := &flakyBus{fakeAskBus: &fakeAskBus{worker: r.worker, answers: r.answers}, fails: 1}
|
||||
askBus = flaky
|
||||
body, err := r.scan(t)
|
||||
if err != nil {
|
||||
t.Fatalf("one failed part fetch ended the scan: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(body["path"].(string)); err != nil {
|
||||
t.Errorf("the scan is not filed: %v", err)
|
||||
}
|
||||
|
||||
f2 := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r2, folder := scanRig(t, f2, Settings{Crop: false})
|
||||
askBus = &flakyBus{fakeAskBus: &fakeAskBus{worker: r2.worker, answers: r2.answers}, fails: 2}
|
||||
_, err = r2.scan(t)
|
||||
if err == nil {
|
||||
t.Fatal("two failed part fetches did not fail the scan")
|
||||
}
|
||||
key := ""
|
||||
if got := *r2.said; len(got) == 1 {
|
||||
key, _ = got[0].body["object"].(string)
|
||||
}
|
||||
if key == "" || !strings.Contains(err.Error(), key) {
|
||||
t.Errorf("the failure %q does not name the stored object %q", err, key)
|
||||
}
|
||||
if entries, _ := os.ReadDir(folder); len(entries) != 0 {
|
||||
t.Errorf("a scan whose parts failed was filed: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,3 +108,26 @@ func (s Settings) MissingPrinter() error {
|
||||
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
|
||||
strings.Join(unset, " and "))
|
||||
}
|
||||
|
||||
// bucketFile is the file the mesh writes from the s3-bucket binding (files/bucket.json): the bucket's name and no secret.
|
||||
// BucketFile is where the tool side reads brother's bucket from: a file the mesh writes from the s3-bucket binding
|
||||
// (files/bucket.json), which holds the bucket's name and no secret.
|
||||
type bucketFile struct {
|
||||
Bucket string `json:"bucket"`
|
||||
}
|
||||
|
||||
// ReadBucket is brother's bucket, as the mesh named it.
|
||||
func ReadBucket(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("the module was started without the bucket's name (BROTHER_BUCKET)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the bucket's name cannot be read: %w", err)
|
||||
}
|
||||
var f bucketFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil || f.Bucket == "" {
|
||||
return "", fmt.Errorf("the bucket file %s names no bucket", path)
|
||||
}
|
||||
return f.Bucket, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"bucket": "${bound:s3-bucket:bucket}"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"mesh-range": "${machine:mesh-range}"
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module brother
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f h1:7J/9jOfSfjfYpf8MOj96HwQRViQUkBNMb71RHP2mjn8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
"module": "brother",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"s3-bucket"
|
||||
{
|
||||
"name": "s3-bucket",
|
||||
"shares": [
|
||||
{
|
||||
"to": "seat:node-files",
|
||||
"methods": [
|
||||
"put"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"invokes": [
|
||||
"seat:node-files.upload"
|
||||
],
|
||||
"binds": {
|
||||
"s3-bucket": "${dir:state}/store-binding.json"
|
||||
@@ -199,6 +212,21 @@
|
||||
"mode": "0600",
|
||||
"template": "files/store.json"
|
||||
},
|
||||
{
|
||||
"id": "bucket",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/bucket.json",
|
||||
"mode": "0644",
|
||||
"template": "files/bucket.json"
|
||||
},
|
||||
{
|
||||
"id": "fetch",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/fetch.json",
|
||||
"owner": "brother-device",
|
||||
"mode": "0600",
|
||||
"template": "files/fetch.json"
|
||||
},
|
||||
{
|
||||
"id": "device",
|
||||
"type": "process",
|
||||
@@ -213,11 +241,13 @@
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json",
|
||||
"BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret",
|
||||
"BROTHER_BROKER": "${dir:state}/broker-device.secret",
|
||||
"BROTHER_STORE": "${dir:state}/store.json"
|
||||
"BROTHER_STORE": "${dir:state}/store.json",
|
||||
"BROTHER_FETCH": "${dir:state}/fetch.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"settings",
|
||||
"store"
|
||||
"store",
|
||||
"fetch"
|
||||
],
|
||||
"bus": {
|
||||
"queues": [
|
||||
@@ -228,6 +258,9 @@
|
||||
],
|
||||
"emits": [
|
||||
"scan.added"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:document-converter.convert"
|
||||
]
|
||||
},
|
||||
"health": {
|
||||
@@ -267,7 +300,8 @@
|
||||
"brother"
|
||||
],
|
||||
"env": {
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json"
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json",
|
||||
"BROTHER_BUCKET": "${dir:state}/bucket.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -476,10 +476,13 @@ type PluginFile struct {
|
||||
// what it is given. Outside hooks/, so no registered hook's name can be it.
|
||||
const GuardDir = "guard"
|
||||
|
||||
// GuardFiles are the guard as the plugin carries it: the binary to copy and the data it judges with.
|
||||
// GuardFiles are the guard as the plugin carries it: the binary to copy, what it judges with, and — the same
|
||||
// binary again — the waits directory the SessionStart and Stop hooks are given (novox/hq ADR 0489, issues
|
||||
// 1744, 1743). Empty WaitsDir places no such hook.
|
||||
type GuardFiles struct {
|
||||
Exe string
|
||||
Data GuardData
|
||||
Exe string
|
||||
Data GuardData
|
||||
WaitsDir string
|
||||
}
|
||||
|
||||
// GuardMatcher is what the guard is asked about: every tool. Its own rules judge the shell and the agent's file
|
||||
@@ -495,6 +498,20 @@ func GuardCommand() string {
|
||||
return `"` + filepath.Join(dir, "guard") + `" guard "` + filepath.Join(dir, "guard.json") + `"`
|
||||
}
|
||||
|
||||
// SessionStartCommand is the plugin's SessionStart hook: the guard's own binary, told the session's open
|
||||
// waits live under waitsDir (novox/hq ADR 0489 §3, issue 1744).
|
||||
func SessionStartCommand(waitsDir string) string {
|
||||
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
||||
return `"` + filepath.Join(dir, "guard") + `" session-start "` + waitsDir + `"`
|
||||
}
|
||||
|
||||
// StopCommand is the plugin's Stop hook: the guard's own binary, refusing to end the turn while a wait under
|
||||
// waitsDir is open and unwatched, or past its bound and untold (novox/hq ADR 0489 §3-§6, issue 1743).
|
||||
func StopCommand(waitsDir string) string {
|
||||
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
||||
return `"` + filepath.Join(dir, "guard") + `" stop "` + waitsDir + `"`
|
||||
}
|
||||
|
||||
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
|
||||
// over a mesh item of the same kind and name: the node's wins.
|
||||
func Marketplace(c Config) map[string]PluginFile { return MarketplaceWith(c, nil) }
|
||||
@@ -533,6 +550,14 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
|
||||
out[root+GuardDir+"/guard.json"] = PluginFile{Content: jsonFile(guard.Data)}
|
||||
hooks["PreToolUse"] = append(hooks["PreToolUse"], map[string]any{"matcher": GuardMatcher,
|
||||
"hooks": []any{map[string]any{"type": "command", "command": GuardCommand(), "timeout": 15}}})
|
||||
if guard.WaitsDir != "" {
|
||||
// A session's wait list (novox/hq ADR 0489): the SessionStart hook names the session's id and its open
|
||||
// waits (issue 1744); the Stop hook keeps the turn open while one is unwatched or untold (issue 1743).
|
||||
hooks["SessionStart"] = append(hooks["SessionStart"], map[string]any{"matcher": "startup|resume|clear|compact",
|
||||
"hooks": []any{map[string]any{"type": "command", "command": SessionStartCommand(guard.WaitsDir), "timeout": 10}}})
|
||||
hooks["Stop"] = append(hooks["Stop"], map[string]any{
|
||||
"hooks": []any{map[string]any{"type": "command", "command": StopCommand(guard.WaitsDir), "timeout": 5}}})
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
it := chosen[k]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
// Every call a hook prints for a session to copy must be accepted by claude_code_wait's own argument check
|
||||
// (issue 1757): the Stop hook's suggested watched and said calls, and the SessionStart hook's suggested
|
||||
// adopt call.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// suggestedCall extracts the one JSON object embedded in a hook's message, right after "claude_code_wait "
|
||||
// — what a session is expected to copy verbatim — and parses it into the arguments a tool call takes.
|
||||
func suggestedCall(t *testing.T, msg string) map[string]any {
|
||||
t.Helper()
|
||||
i := strings.Index(msg, "claude_code_wait ")
|
||||
if i < 0 {
|
||||
t.Fatalf("no suggested call in: %s", msg)
|
||||
}
|
||||
rest := msg[i+len("claude_code_wait "):]
|
||||
start := strings.Index(rest, "{")
|
||||
if start < 0 {
|
||||
t.Fatalf("no JSON object in: %s", msg)
|
||||
}
|
||||
depth, end := 0, -1
|
||||
for j, c := range rest[start:] {
|
||||
switch c {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = start + j + 1
|
||||
}
|
||||
}
|
||||
if end >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
t.Fatalf("unbalanced JSON object in: %s", msg)
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(rest[start:end]), &args); err != nil {
|
||||
t.Fatalf("the suggested call is not valid JSON: %v (%s)", err, rest[start:end])
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
|
||||
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], t0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested watched call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
|
||||
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
clock := t0
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
clock = t0.Add(2 * time.Minute) // past its 1-minute bound, for the live tool call too
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], clock)
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested said call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
|
||||
// regression here is caught the same way as the Stop hook's.
|
||||
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
|
||||
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
|
||||
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
|
||||
}
|
||||
args := suggestedCall(t, msg)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested adopt call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
// Shared by the plugin's hooks (novox/hq ADR 0489 §5): a notice on exit 0 — the stop hook failing open or
|
||||
// letting a wait through, the session-start hook unable to use a session id — reaches the operator's screen,
|
||||
// not only the log. Claude Code shows a hook's top-level "systemMessage" field to the person; stderr alone,
|
||||
// on an allowed (exit 0) outcome, is never shown there.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// writeSystemMessage prints message as the one JSON object Claude Code shows the operator, falling back to
|
||||
// the plain words on the one error that cannot happen (a string always marshals).
|
||||
func writeSystemMessage(w io.Writer, message string) {
|
||||
raw, err := json.Marshal(map[string]string{"systemMessage": message})
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, message)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, string(raw))
|
||||
}
|
||||
@@ -388,6 +388,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
|
||||
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
|
||||
out = append(out, proposalTools(p)...)
|
||||
out = append(out, waitTools(p)...)
|
||||
judge := NewJudge(p)
|
||||
out = append(out, judgeTool(judge), judgedTool(judge))
|
||||
return append(out, configTools(p, config, configView)...)
|
||||
@@ -416,6 +417,15 @@ func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "guard" {
|
||||
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
|
||||
}
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744): names the session's id and its open waits.
|
||||
if len(os.Args) == 3 && os.Args[1] == "session-start" {
|
||||
os.Exit(runSessionStart(os.Args[2], os.Stdin, os.Stdout))
|
||||
}
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): keeps the turn open while a wait is unwatched
|
||||
// or untold.
|
||||
if len(os.Args) == 3 && os.Args[1] == "stop" {
|
||||
os.Exit(runStop(os.Args[2], os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
// The helper that touches the agent's home as the agent's account (novox/hq ADR 0266), started by this
|
||||
// module through sudo: one answer per request on stdin, until it ends.
|
||||
if len(os.Args) == 2 && os.Args[1] == HelperArg {
|
||||
@@ -463,6 +473,7 @@ func main() {
|
||||
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
||||
Now: time.Now, Write: writeManaged}
|
||||
go proposing.watch(p)
|
||||
waiting = &Waiting{Store: keptWaits{stdio.State(WaitsState)}, Now: time.Now}
|
||||
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
|
||||
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
|
||||
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
|
||||
|
||||
@@ -278,7 +278,7 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles {
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m)}
|
||||
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)}
|
||||
}
|
||||
|
||||
// MeshToolsEvery is how often the controller is asked again.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, ADR 0490 §2, issue 1744): `claude-code session-start
|
||||
// <waits-dir>`, run by the agent's own account on every start, resume, /clear and /compact. The tool runner
|
||||
// never tells a session its own id (only a hook's stdin carries session_id), so a session cannot name itself
|
||||
// to claude_code_wait until this hook has told it. It also lists this session id's own still-open waits (if
|
||||
// any name is already bound to it) and, always, every name on this node that still holds an open wait, so a
|
||||
// session with a brand new id (a /clear gives one) can see a name it may want to adopt.
|
||||
//
|
||||
// It reads the session's mirrored waits file and the node-wide index directly — the same local reads the
|
||||
// Stop hook and claude_code_wait make (readWaitsFile, readNodeIndex, waits.go) — never calling back into
|
||||
// this module: this hook runs on every start, and must not wait on the bus to do it. A missing or malformed
|
||||
// file is not an error here: there is simply nothing open to report.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionStartInput is what the hook is given on stdin.
|
||||
type SessionStartInput struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// SessionStartMessage is the line the hook prints: the session's id and how to give it to claude_code_wait,
|
||||
// then each still-open wait already bound to this session id (1744/AC1, AC2), then every name on this node
|
||||
// that still holds an open wait, with how to adopt it (ADR 0490 §2). Pure of IO beyond the two file reads.
|
||||
func SessionStartMessage(sessionID, waitsDir string, now time.Time) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "This session's id is %s; give it as `session` to claude_code_wait.\n", sessionID)
|
||||
if f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)); err == nil && len(f.Waits) > 0 {
|
||||
b.WriteString("Waits already bound to this session's own id:\n")
|
||||
for _, w := range f.Waits {
|
||||
age := now.Sub(w.At).Round(time.Second)
|
||||
state := "no watcher yet"
|
||||
if w.Watcher != "" {
|
||||
state = "watched"
|
||||
}
|
||||
if w.PastBound(now) {
|
||||
state += ", past its bound"
|
||||
if w.Said {
|
||||
state += ", said"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, " %s — %s (%s ago, %s)\n", w.ID, w.What, age, state)
|
||||
}
|
||||
}
|
||||
if idx, err := readNodeIndex(nodeIndexPathIn(waitsDir)); err == nil && len(idx.Names) > 0 {
|
||||
b.WriteString("Open waits on this node, by name:\n")
|
||||
for _, n := range idx.Names {
|
||||
fmt.Fprintf(&b, " %s: %d open wait(s), last called %s. If this is you: claude_code_wait "+
|
||||
"{\"op\":\"adopt\",\"by\":%q,\"session\":%q}\n", n.Name, n.Open, n.LastCall.Round(time.Second).Format(time.RFC3339),
|
||||
n.Name+"@"+idx.Node, sessionID)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runSessionStart is the hook: the session's id and its open waits out, exit 0 always (1744/AC3).
|
||||
func runSessionStart(waitsDir string, stdin io.Reader, stdout io.Writer) (code int) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
code = 0 // never fails the session's start
|
||||
}
|
||||
}()
|
||||
var in SessionStartInput
|
||||
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
|
||||
return 0
|
||||
}
|
||||
sessionID := strings.TrimSpace(in.SessionID)
|
||||
if sessionID == "" {
|
||||
return 0
|
||||
}
|
||||
// Review round 1, item 1: a session id is never used to build a path until it is checked against
|
||||
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
|
||||
if !waitSessionID.MatchString(sessionID) {
|
||||
writeSystemMessage(stdout, fmt.Sprintf("this session's id (%q) could not be used to find its waits "+
|
||||
"(letters, digits, '.', '_' and '-' only)", sessionID))
|
||||
return 0
|
||||
}
|
||||
fmt.Fprint(stdout, SessionStartMessage(sessionID, waitsDir, time.Now()))
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 1744/AC1: the hook prints the session's id and how to give it to claude_code_wait.
|
||||
func TestSessionStartPrintsTheSessionsIDAndHowToGiveIt(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
stdin := strings.NewReader(`{"session_id":"sess-9","hook_event_name":"SessionStart","source":"startup"}`)
|
||||
var out bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
|
||||
t.Fatalf("code %d, stdout %s", code, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "sess-9") || !strings.Contains(out.String(), "claude_code_wait") {
|
||||
t.Fatalf("stdout does not say how to name the session: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1744/AC2: when the waits file holds open waits, it prints each (what, age, past bound or not).
|
||||
func TestSessionStartListsOpenWaitsAfterAClear(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
at := time.Date(2026, 10, 21, 10, 0, 0, 0, time.UTC)
|
||||
f := WaitsFile{Session: "sess-10", At: at, Waits: []Wait{
|
||||
{ID: "w-open", What: "the operator's Approve", Bound: 10, At: at},
|
||||
{ID: "w-past", What: "a delivery to finish", Bound: 1, At: at},
|
||||
}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-10.json"), f)
|
||||
|
||||
now := at.Add(5 * time.Minute)
|
||||
msg := SessionStartMessage("sess-10", waitsDir, now)
|
||||
if !strings.Contains(msg, "w-open") || !strings.Contains(msg, "the operator's Approve") || !strings.Contains(msg, "no watcher") {
|
||||
t.Fatalf("the open, unwatched wait is not described:\n%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "w-past") || !strings.Contains(msg, "past its bound") {
|
||||
t.Fatalf("the past-bound wait is not described:\n%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "5m0s ago") {
|
||||
t.Fatalf("the age is not said:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 1744/AC3: a missing or malformed file prints only the id line, and the hook never fails the session start.
|
||||
func TestSessionStartNeverFailsOnAMissingOrBadFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
if msg := SessionStartMessage("sess-11", waitsDir, time.Now()); !strings.Contains(msg, "sess-11") || strings.Contains(msg, "open") {
|
||||
t.Fatalf("a missing file printed more than the id line: %q", msg)
|
||||
}
|
||||
writeFile(t, filepath.Join(waitsDir, "sess-12.json"), "{not json")
|
||||
if msg := SessionStartMessage("sess-12", waitsDir, time.Now()); !strings.Contains(msg, "sess-12") || strings.Contains(msg, "open") {
|
||||
t.Fatalf("a malformed file printed more than the id line: %q", msg)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, strings.NewReader("not json at all"), &out); code != 0 {
|
||||
t.Fatalf("unreadable stdin failed the session start: code %d", code)
|
||||
}
|
||||
var out2 bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, panicReader{}, &out2); code != 0 {
|
||||
t.Fatalf("a panic was not recovered: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// panicReader panics on Read, as a stuck pipe or a bug reading stdin might.
|
||||
type panicReader struct{}
|
||||
|
||||
func (panicReader) Read([]byte) (int, error) { panic("boom") }
|
||||
|
||||
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one is never used to
|
||||
// build a file path — reading outside the waits dir never happens — and the hook prints only a line saying
|
||||
// the id could not be used.
|
||||
func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
waitsDir := filepath.Join(root, "agent", "waits")
|
||||
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What "../../x" resolves to from waitsDir: root/x.json, outside waitsDir, with content that must never be
|
||||
// read through it.
|
||||
outside := filepath.Join(waitsDir, "../../x.json")
|
||||
writeWaitsFixture(t, outside, WaitsFile{Session: "x", At: time.Now(),
|
||||
Waits: []Wait{{ID: "w-secret", What: "SECRET-DATA", Bound: 10, At: time.Now()}}})
|
||||
t.Cleanup(func() { _ = os.Remove(outside) })
|
||||
|
||||
for _, id := range []string{"../../x", "a/b"} {
|
||||
var out bytes.Buffer
|
||||
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q}`, id))
|
||||
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
|
||||
t.Fatalf("id %q: code %d", id, code)
|
||||
}
|
||||
if strings.Contains(out.String(), "SECRET-DATA") {
|
||||
t.Fatalf("id %q: read a file outside the waits dir: %s", id, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "could not be used") {
|
||||
t.Fatalf("id %q: did not say the id could not be used: %s", id, out.String())
|
||||
}
|
||||
// Item (a): the notice reaches the screen as the one JSON object Claude Code shows.
|
||||
if !strings.Contains(out.String(), "systemMessage") {
|
||||
t.Fatalf("id %q: the notice is not a systemMessage: %s", id, out.String())
|
||||
}
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, strings.NewReader(`{"session_id":""}`), &out); code != 0 || out.Len() != 0 {
|
||||
t.Fatalf("blank id: code %d, out %q", code, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1744/AC4 is checked together with 1743/AC7 in terminal_only_test.go's
|
||||
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook.
|
||||
|
||||
// ADR 0490 §2: every session start lists every name on this node with an open wait, from the node-wide
|
||||
// index, with the exact adopt call to make if it is yours — read directly, never by calling back into this
|
||||
// module.
|
||||
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
|
||||
idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{
|
||||
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
|
||||
}}
|
||||
raw, err := indented(idx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(waitsDir, "_index.json"), string(raw))
|
||||
|
||||
msg := SessionStartMessage("sess-brand-new", waitsDir, lastCall.Add(5*time.Minute))
|
||||
if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") {
|
||||
t.Fatalf("the name and its count are not listed:\n%s", msg)
|
||||
}
|
||||
want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}`
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
func writeWaitsFixture(t *testing.T, path string, f WaitsFile) {
|
||||
t.Helper()
|
||||
raw, err := indented(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, path, string(raw))
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): `claude-code stop <waits-dir>`, run by the
|
||||
// agent's own account before a session's turn is let end. It reads the session's mirrored waits file
|
||||
// directly (readWaitsFile, waits.go) — never the bus, never calling back into this module — so the decision
|
||||
// is local, under 5 s, and needs no network.
|
||||
//
|
||||
// Within its bound, a wait needs a watcher: refused while one is open and nothing follows it yet. Past its
|
||||
// bound, a wait needs a word to the operator, watched or not: refused until it is marked said. Refused at
|
||||
// most three times running for the same wait (a counter file beside the waits file, reset once the blocking
|
||||
// wait changes or clears); the fourth time it is let through, saying the wait was left unwatched. A missing,
|
||||
// unreadable or malformed waits file, or a panic, always allows.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StopInput is what the hook is given on stdin.
|
||||
type StopInput struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
StopHookActive bool `json:"stop_hook_active"`
|
||||
}
|
||||
|
||||
// MostStopRefusals is how many times in a row the Stop hook refuses for the same wait before it lets the
|
||||
// turn end anyway (1743/AC5).
|
||||
const MostStopRefusals = 3
|
||||
|
||||
// StopDecision is the Stop hook's verdict, and the refusal counters it leaves behind for the next call.
|
||||
type StopDecision struct {
|
||||
Refuse bool
|
||||
Reason string
|
||||
Counters map[string]int
|
||||
}
|
||||
|
||||
// waitBlocksStop says whether w, on its own, keeps the turn from ending, and why (ADR 0489 §3-§4).
|
||||
func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
|
||||
if w.Said {
|
||||
return false, ""
|
||||
}
|
||||
if !w.PastBound(now) {
|
||||
if w.Watcher != "" {
|
||||
return false, ""
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+
|
||||
"%s, and mark it watched: claude_code_wait "+
|
||||
"{\"op\":\"watched\",\"session\":%q,\"by\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address,
|
||||
watcherAdvice(w), session, w.By, w.ID)
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+
|
||||
"waits, since when, and what you are doing about it, then mark it: claude_code_wait "+
|
||||
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
|
||||
}
|
||||
|
||||
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
|
||||
// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook
|
||||
// cannot know, so it is offered conditionally; a watcher that rereads the read address always works.
|
||||
func watcherAdvice(w Wait) string {
|
||||
if w.Family != "" {
|
||||
return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+
|
||||
"that rereads %s", w.Family, w.Read.Address)
|
||||
}
|
||||
return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address)
|
||||
}
|
||||
|
||||
// DecideStop is the Stop hook's decision, pure of IO: f and readErr are what reading the session's waits
|
||||
// file gave, previous the refusal counters read beside it, and now the time. Only the first wait that still
|
||||
// blocks the stop is named; the others are read again next time.
|
||||
//
|
||||
// A missing file (review round 2, item 1) is the normal state for a session with no waits — every stop of
|
||||
// every such session, not an exceptional one — so it says nothing and simply allows, the same as a file that
|
||||
// reads back with no open waits. Only a file that exists but cannot be read, or does not parse, says so.
|
||||
func DecideStop(session string, f WaitsFile, readErr error, previous map[string]int, now time.Time) StopDecision {
|
||||
if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
|
||||
return StopDecision{Reason: fmt.Sprintf("the wait list could not be read (%v); allowed", readErr), Counters: map[string]int{}}
|
||||
}
|
||||
for _, w := range f.Waits {
|
||||
blocks, reason := waitBlocksStop(session, w, now)
|
||||
if !blocks {
|
||||
continue
|
||||
}
|
||||
count := previous[w.ID] + 1
|
||||
if count > MostStopRefusals {
|
||||
return StopDecision{Reason: fmt.Sprintf("wait %s was refused %d times running and is let through: it was left "+
|
||||
"unwatched (%s)", w.ID, MostStopRefusals, reason), Counters: map[string]int{}}
|
||||
}
|
||||
return StopDecision{Refuse: true, Reason: reason, Counters: map[string]int{w.ID: count}}
|
||||
}
|
||||
return StopDecision{Counters: map[string]int{}}
|
||||
}
|
||||
|
||||
func refusalCountersPath(waitsDir, session string) string {
|
||||
return filepath.Join(waitsDir, session+".refusals.json")
|
||||
}
|
||||
|
||||
func readRefusalCounters(waitsDir, session string) map[string]int {
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, session))
|
||||
if err != nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
var m map[string]int
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// writeRefusalCounters keeps the counter file beside the waits file in step with m, through fsys like every
|
||||
// other write into the agent's home (novox/hq ADR 0266, issue 1749): called from the Stop hook, which
|
||||
// already runs as the agent's own account (fsys is then its zero value, a plain write or remove, as
|
||||
// before), and from the tool's AdoptName, which runs as the operator's account and must reach a confined
|
||||
// node's agent home through the sudo helper fsys carries such a write through. Its caller decides what an
|
||||
// error here means: harmless when the stop is already allowed, but when the stop was about to be refused, a
|
||||
// cap that cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1
|
||||
// item 2).
|
||||
//
|
||||
// Empty, m is removed rather than written as "{}" (issue 1749, follow-up): a stop with nothing to record —
|
||||
// every stop of every session with no waits, the ordinary case — must leave neither the file nor, the first
|
||||
// time, the waits folder itself; removing a file that was never there is not an error.
|
||||
func writeRefusalCounters(waitsDir, session string, m map[string]int) error {
|
||||
path := refusalCountersPath(waitsDir, session)
|
||||
if len(m) == 0 {
|
||||
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fsys.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.WriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
// notice says a fail-open or let-through reason on both the log (stderr) and the screen: Claude Code shows a
|
||||
// hook's "systemMessage" on stdout, but not stderr, when the hook allows (review round 1, item a).
|
||||
func notice(stdout, stderr io.Writer, reason string) {
|
||||
fmt.Fprintln(stderr, reason)
|
||||
writeSystemMessage(stdout, reason)
|
||||
}
|
||||
|
||||
// runStop is the hook: exit 2 with the reason on stderr refuses the stop, exit 0 allows (1743).
|
||||
func runStop(waitsDir string, stdin io.Reader, stdout, stderr io.Writer) (code int) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
code = 0 // a panic always allows (1743/AC6)
|
||||
}
|
||||
}()
|
||||
var in StopInput
|
||||
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
|
||||
return 0
|
||||
}
|
||||
sessionID := strings.TrimSpace(in.SessionID)
|
||||
if sessionID == "" {
|
||||
return 0
|
||||
}
|
||||
// Review round 1, item 1: a session id is never used to build a path until it is checked against
|
||||
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
|
||||
if !waitSessionID.MatchString(sessionID) {
|
||||
notice(stdout, stderr, fmt.Sprintf("the session id %q could not be used to find its waits (letters, digits, "+
|
||||
"'.', '_' and '-' only); this stop is allowed", sessionID))
|
||||
return 0
|
||||
}
|
||||
f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID))
|
||||
counters := readRefusalCounters(waitsDir, sessionID)
|
||||
d := DecideStop(sessionID, f, err, counters, time.Now())
|
||||
if werr := writeRefusalCounters(waitsDir, sessionID, d.Counters); werr != nil && d.Refuse {
|
||||
// Item 2: writing the counter failed on the very call that would have incremented it toward the cap;
|
||||
// refusing anyway could hold the turn forever, so this call is let through instead.
|
||||
notice(stdout, stderr, fmt.Sprintf("this wait's refusal counter could not be written (%v); allowed so it "+
|
||||
"cannot hold the turn forever", werr))
|
||||
return 0
|
||||
}
|
||||
if d.Reason != "" {
|
||||
fmt.Fprintln(stderr, d.Reason)
|
||||
if !d.Refuse {
|
||||
writeSystemMessage(stdout, d.Reason) // item a: a fail-open or let-through notice also reaches the screen
|
||||
}
|
||||
}
|
||||
if d.Refuse {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var stopT0 = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func openWait(id string, bound int) Wait {
|
||||
return Wait{ID: id, What: "the operator's Approve", Bound: bound, At: stopT0}
|
||||
}
|
||||
|
||||
// 1743/AC1: a wait within its bound and unwatched refuses the stop, naming the wait, its read address and
|
||||
// what the session does about it (mesh_wait on its family, conditionally — review round 1 item 3 — or a
|
||||
// watcher that rereads the address).
|
||||
func TestStopRefusesAnUnwatchedWaitWithinItsBound(t *testing.T) {
|
||||
w := openWait("w-1", 10)
|
||||
w.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
w.Family = "mesh-delivery.decided"
|
||||
d := DecideStop("sess-1", WaitsFile{Waits: []Wait{w}}, nil, map[string]int{}, stopT0.Add(time.Minute))
|
||||
if !d.Refuse || !strings.Contains(d.Reason, "w-1") || !strings.Contains(d.Reason, "mesh-delivery.recheck") ||
|
||||
!strings.Contains(d.Reason, "mesh_wait") || !strings.Contains(d.Reason, "watcher") {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC2: a watched wait within its bound, a done wait (absent from the file), or no file entry: allowed.
|
||||
func TestStopAllowsAWatchedOrDoneOrAbsentWait(t *testing.T) {
|
||||
watched := openWait("w-2", 10)
|
||||
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
||||
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(time.Minute)); d.Refuse {
|
||||
t.Fatalf("a watched wait within its bound was refused: %+v", d)
|
||||
}
|
||||
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{}}, nil, map[string]int{}, stopT0); d.Refuse {
|
||||
t.Fatalf("no open waits was refused: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC3: a wait past its bound, watched or not, is refused until said, then allowed.
|
||||
func TestStopRefusesPastBoundUntilSaidWatchedOrNot(t *testing.T) {
|
||||
unwatched := openWait("w-3", 5)
|
||||
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{unwatched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse ||
|
||||
!strings.Contains(d.Reason, "w-3") || !strings.Contains(d.Reason, "said") {
|
||||
t.Fatalf("unwatched past bound: %+v", d)
|
||||
}
|
||||
watched := unwatched
|
||||
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
||||
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse {
|
||||
t.Fatalf("watched past bound was allowed: %+v", d)
|
||||
}
|
||||
said := watched
|
||||
said.Said = true
|
||||
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{said}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); d.Refuse {
|
||||
t.Fatalf("said past bound was still refused: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC4: an unreadable or malformed waits file is allowed, saying so on stderr and as a systemMessage on
|
||||
// stdout; the decision does not touch the network and answers at once.
|
||||
func TestStopAllowsAndSaysSoWhenTheWaitsFileIsUnreadableOrMalformed(t *testing.T) {
|
||||
d := DecideStop("sess-4", WaitsFile{}, errors.New("no such file"), map[string]int{}, stopT0)
|
||||
if d.Refuse || !strings.Contains(d.Reason, "could not be read") {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
waitsDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(waitsDir, "sess-4b.json"), []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-4b","stop_hook_active":false}`)
|
||||
start := time.Now()
|
||||
code := runStop(waitsDir, stdin, &stdout, &stderr)
|
||||
if code != 0 || !strings.Contains(stderr.String(), "could not be read") {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
// Item (a): a fail-open notice also reaches the screen, as the one JSON object Claude Code shows.
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "could not be read") {
|
||||
t.Fatalf("the fail-open notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
if took := time.Since(start); took > 5*time.Second {
|
||||
t.Fatalf("took %s; the Stop hook has no network and is bounded under 5s", took)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item 1 (1743/AC2, ADR 0490 §2): a missing waits file is the normal state for a session
|
||||
// with no waits — every stop of every such session — so it must print nothing, on either stream, and
|
||||
// simply allow; a notice there would fire constantly for nothing wrong.
|
||||
func TestStopPrintsNothingWhenTheWaitsFileIsSimplyMissing(t *testing.T) {
|
||||
d := DecideStop("sess-4c", WaitsFile{}, fs.ErrNotExist, map[string]int{}, stopT0)
|
||||
if d.Refuse || d.Reason != "" {
|
||||
t.Fatalf("a missing file (fs.ErrNotExist) said something: %+v", d)
|
||||
}
|
||||
waitsDir := t.TempDir() // no file at all for sess-4d
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-4d","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d", code)
|
||||
}
|
||||
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("a missing waits file printed something: stdout %q, stderr %q", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC5: the same stop refused three times running for the same wait is allowed the fourth time, saying
|
||||
// it was left unwatched; the counter resets when the wait changes.
|
||||
func TestStopAllowsTheFourthRefusalInARow(t *testing.T) {
|
||||
w := openWait("w-5", 10)
|
||||
counters := map[string]int{}
|
||||
for i := 1; i <= MostStopRefusals; i++ {
|
||||
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
||||
if !d.Refuse {
|
||||
t.Fatalf("refusal %d was allowed: %+v", i, d)
|
||||
}
|
||||
counters = d.Counters
|
||||
}
|
||||
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
||||
if d.Refuse || !strings.Contains(d.Reason, "unwatched") {
|
||||
t.Fatalf("the fourth refusal was not let through: %+v", d)
|
||||
}
|
||||
if len(d.Counters) != 0 {
|
||||
t.Fatalf("the counter was not reset once let through: %+v", d.Counters)
|
||||
}
|
||||
|
||||
// A different wait starts its own count from zero.
|
||||
w2 := openWait("w-5b", 10)
|
||||
d2 := DecideStop("sess-5", WaitsFile{Waits: []Wait{w2}}, nil, counters, stopT0.Add(time.Minute))
|
||||
if !d2.Refuse || d2.Counters["w-5b"] != 1 {
|
||||
t.Fatalf("a different wait did not start its own count: %+v", d2)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC6: a panic answers allow.
|
||||
func TestStopAPanicAllows(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, panicReader{}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("a panic was not recovered: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// runStop through its counter file: three refusals in a row for the one open wait, then let through.
|
||||
func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
f := WaitsFile{Session: "sess-6", At: stopT0, Waits: []Wait{openWait("w-6", 10)}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-6.json"), f)
|
||||
stdin := func() *strings.Reader { return strings.NewReader(`{"session_id":"sess-6","stop_hook_active":false}`) }
|
||||
|
||||
for i := 1; i <= MostStopRefusals; i++ {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("refusal %d: code %d, stderr %s", i, code, stderr.String())
|
||||
}
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 0 || !strings.Contains(stderr.String(), "unwatched") {
|
||||
t.Fatalf("the fourth call: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
// Item (a): the let-through notice reaches the screen too, not only the log.
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "unwatched") {
|
||||
t.Fatalf("the let-through notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
// Once let through, the counters are empty again: the file is removed, not left holding "{}" (issue
|
||||
// 1749, follow-up).
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-6")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed once its counters emptied: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a stop for a session with no waits at all leaves nothing behind — not the counter
|
||||
// file, not even the waits folder a node that never added a wait would otherwise gain on every stop of
|
||||
// every session.
|
||||
func TestStopWithNoWaitsLeavesNoFolderAndNoFile(t *testing.T) {
|
||||
waitsDir := filepath.Join(t.TempDir(), "waits") // not created: this is what the hook is handed cold
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-none","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(waitsDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("a stop with no waits created the waits folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a refusal writes the counter file (there is something to record).
|
||||
func TestStopWithARefusalWritesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
f := WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"), f)
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-7"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var counters map[string]int
|
||||
if json.Unmarshal(raw, &counters) != nil || counters["w-7"] != 1 {
|
||||
t.Fatalf("the counter file: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: once the wait is done (no longer in the waits file), the next stop removes the
|
||||
// counter file rather than leaving a stale "{}" behind.
|
||||
func TestStopAfterTheWaitIsDoneRemovesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
path := filepath.Join(waitsDir, "sess-8.json")
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{openWait("w-8", 10)}})
|
||||
stdin := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("refusal: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); err != nil {
|
||||
t.Fatalf("the counter file was not written: %v", err)
|
||||
}
|
||||
|
||||
// The wait is done: it is no longer in the waits file (DoneWait removes it, waits.go).
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{}})
|
||||
var stdout2, stderr2 bytes.Buffer
|
||||
stdin2 := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin2, &stdout2, &stderr2); code != 0 {
|
||||
t.Fatalf("after done: code %d, stderr %s", code, stderr2.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed after the wait was done: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one (path separators,
|
||||
// ".."), is never used to build a file path — the counter file stays inside the waits directory — and the
|
||||
// Stop hook allows, saying why.
|
||||
func TestStopRejectsAnUnsafeSessionID(t *testing.T) {
|
||||
for _, id := range []string{"../../x", "a/b"} {
|
||||
root := t.TempDir()
|
||||
waitsDir := filepath.Join(root, "agent", "waits")
|
||||
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q,"stop_hook_active":false}`, id))
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("id %q: code %d, stderr %s", id, code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "could not be used") {
|
||||
t.Fatalf("id %q: did not say why it allowed: %s", id, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "systemMessage") {
|
||||
t.Fatalf("id %q: the notice did not reach stdout: %s", id, stdout.String())
|
||||
}
|
||||
escaped := filepath.Join(waitsDir, id+".refusals.json")
|
||||
if _, err := os.Stat(escaped); !os.IsNotExist(err) {
|
||||
t.Fatalf("id %q: a counter file was written outside the waits dir at %s", id, escaped)
|
||||
}
|
||||
}
|
||||
// A blank id still just allows, quietly: it never reaches the point of building a path at all.
|
||||
waitsDir := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, strings.NewReader(`{"session_id":""}`), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("blank id: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 2 (ADR 0489 §5 "never loops", 1743/AC5): when the decision is to refuse and the
|
||||
// refusal counter cannot be written, the hook allows rather than risk refusing forever with a cap that
|
||||
// never takes effect.
|
||||
func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("running as root: a directory's write bit cannot be taken away")
|
||||
}
|
||||
waitsDir := t.TempDir()
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"),
|
||||
WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}})
|
||||
if err := os.Chmod(waitsDir, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(waitsDir, 0o700) })
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "counter") {
|
||||
t.Fatalf("did not say the counter could not be written: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "systemMessage") {
|
||||
t.Fatalf("the notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
|
||||
// runner may not be granted it); without one, the advice is only to start a watcher.
|
||||
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
|
||||
withFamily := openWait("w-f", 10)
|
||||
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
withFamily.Family = "mesh-delivery.decided"
|
||||
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
|
||||
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
|
||||
t.Fatalf("%s", reason)
|
||||
}
|
||||
|
||||
noFamily := openWait("w-nf", 10)
|
||||
noFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
_, reason2 := waitBlocksStop("sess-nf", noFamily, stopT0.Add(time.Minute))
|
||||
if strings.Contains(reason2, "mesh_wait") || !strings.Contains(reason2, "start a watcher that rereads mesh-delivery.recheck") {
|
||||
t.Fatalf("%s", reason2)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC7's rendering half (hooks.json holds the guard's PreToolUse entry and the Stop entry) is checked
|
||||
// together with 1744/AC4 in terminal_only_test.go's
|
||||
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook. claude_code_hook_register still
|
||||
// refuses a Stop hook (or any other) at every scope.
|
||||
func TestHookRegisterStillRefusesEveryHook(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
text, isError := s.call(t, "claude_code_hook_register", map[string]any{"name": "x", "event": "Stop", "command": "true"})
|
||||
if !isError || !strings.Contains(text, "issue 340") {
|
||||
t.Fatalf("a Stop hook was not refused: %v %s", isError, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749: adopt's refusal-counter reset writes into the agent's home (beside the waits file) and must
|
||||
// go through fsys like every other such write (novox/hq ADR 0266) — on a confined node the module runs as
|
||||
// the operator's account, which cannot write there directly; only the helper, as the agent's own account,
|
||||
// can. The confined-node fake (agentFiles, agent_account_test.go) proves it: a write that reaches the real
|
||||
// filesystem only through the helper's request/response protocol bumps its count.
|
||||
func TestWriteRefusalCountersGoesThroughTheHelper(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
was := fsys
|
||||
fsys = f
|
||||
t.Cleanup(func() { fsys = was })
|
||||
|
||||
waitsDir := filepath.Join(home, ".claude", "nox-mesh", "waits")
|
||||
if err := writeRefusalCounters(waitsDir, "sess-1", map[string]int{"w-1": 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counted.asked.Load() == 0 {
|
||||
t.Fatal("the counter file was written directly, never through the helper")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(waitsDir, "sess-1.refusals.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]int
|
||||
if json.Unmarshal(raw, &m) != nil || m["w-1"] != 2 {
|
||||
t.Fatalf("the written counters: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -261,12 +261,12 @@ func TestTheOperatorsManagedSettingsAreWhatSessionsObey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The plugin's hooks are the mesh's own guard on the shell (novox/hq ADR 0245) and nothing else, whatever the
|
||||
// configuration given holds (novox/hq issue 340).
|
||||
func TestThePluginsOnlyHookIsTheGuard(t *testing.T) {
|
||||
// The plugin's only hooks are the guard, the session-start hook and the Stop hook (novox/hq ADR 0245, ADR
|
||||
// 0489) — never a hook a registration named, whatever the configuration given holds (novox/hq issue 340).
|
||||
func TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook(t *testing.T) {
|
||||
c := Config{Mesh: []Item{{Kind: KindHook, Name: "planted", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
|
||||
Command: "${HOOK_DIR}/x.sh", Files: map[string]string{"x.sh": "#!/bin/sh\n"}}}}
|
||||
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true"})
|
||||
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true", WaitsDir: "/home/agent/.claude/nox-mesh/waits"})
|
||||
root := "plugins/" + Plugin + "/"
|
||||
var hooks struct {
|
||||
Hooks map[string][]struct {
|
||||
@@ -279,9 +279,16 @@ func TestThePluginsOnlyHookIsTheGuard(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hooks.Hooks) != 1 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
|
||||
if len(hooks.Hooks) != 3 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
|
||||
hooks.Hooks["PreToolUse"][0].Hooks[0].Command != GuardCommand() {
|
||||
t.Fatalf("the plugin's hooks: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
t.Fatalf("the guard's hook: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
if len(hooks.Hooks["SessionStart"]) != 1 || hooks.Hooks["SessionStart"][0].Matcher != "startup|resume|clear|compact" ||
|
||||
hooks.Hooks["SessionStart"][0].Hooks[0].Command != SessionStartCommand("/home/agent/.claude/nox-mesh/waits") {
|
||||
t.Fatalf("the session-start hook: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
if len(hooks.Hooks["Stop"]) != 1 || hooks.Hooks["Stop"][0].Hooks[0].Command != StopCommand("/home/agent/.claude/nox-mesh/waits") {
|
||||
t.Fatalf("the Stop hook: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
for path := range plugin {
|
||||
if strings.HasPrefix(path, root+"hooks/planted") {
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
package main
|
||||
|
||||
// A session's wait list (novox/hq ADR 0489, as changed by ADR 0490): what a session still expects an answer
|
||||
// to — an Approve, a delivery's end, anything it started and must act on when it ends
|
||||
// (follow-what-you-start) — so the plugin's Stop hook can refuse to end its turn while one is open and
|
||||
// nothing watches it.
|
||||
//
|
||||
// Kept in the module's `waits` state under the session's name and this node (ADR 0490 §1) — `by`, as
|
||||
// claude_code_ask takes it, never the session id, which Claude Code hands a session a new one of at a
|
||||
// /clear. Every call also gives `session`, the caller's current id; the module keeps the binding of a name
|
||||
// to the session that last called for it, with that call's time, so the same name's waits are found again
|
||||
// across a /clear once the new session has adopted it.
|
||||
//
|
||||
// The waits file a hook reads is kept per session id, not per name, and holds the waits of whichever name is
|
||||
// currently bound to that id (empty for an id nothing is bound to): written whole, atomically, on every
|
||||
// change, and rewritten for both ids when a binding moves (AdoptName). A second file, the node-wide index,
|
||||
// lists every name on this node that still holds an open wait, so the SessionStart hook can tell a session
|
||||
// with a brand new id what it might want to adopt, without calling back into this module.
|
||||
//
|
||||
// Every file lives under the agent's home (the manifest's `agent-home` resource), which this process —
|
||||
// launched as the operator's account (novox/hq ADR 0193) — cannot itself write when the node names an agent
|
||||
// account of its own (novox/hq ADR 0266): every write below goes through `fsys` (homefs.go), which already
|
||||
// carries such a write through the sudo helper the guard's binary also serves, exactly as grant.go's
|
||||
// WriteCredentials does for the agent's own credentials file. Where the node has no agent account of its
|
||||
// own, fsys is the plain filesystem and the write reaches the operator's own home directly.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// WaitsState is the module's state the wait lists are kept in.
|
||||
const WaitsState = "waits"
|
||||
|
||||
// WaitsDir is where the mirrored files live under the agent's home.
|
||||
const WaitsDir = "nox-mesh/waits"
|
||||
|
||||
// WaitPrefix starts every wait's id.
|
||||
const WaitPrefix = "w-"
|
||||
|
||||
// MostWaits is how many open waits one name may hold at once (novox/hq ADR 0489, 1629/AC5).
|
||||
const MostWaits = 50
|
||||
|
||||
// The bounds of a wait's bound, in minutes (1629/AC5).
|
||||
const (
|
||||
ShortestWaitBound = 1
|
||||
LongestWaitBound = 1440
|
||||
)
|
||||
|
||||
// mostWaitAttempts bounds the compare-and-set retry loop over the `waits` state.
|
||||
const mostWaitAttempts = 20
|
||||
|
||||
var waitSessionID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
||||
|
||||
// requireSessionID trims session and refuses it unless it is a plain id matching waitSessionID — every op
|
||||
// holds to this (review round 1, item b), so a session id is never used to build a path from anything but a
|
||||
// safe name (no "/", so it cannot name a file outside the waits directory).
|
||||
func requireSessionID(session string) (string, error) {
|
||||
session = strings.TrimSpace(session)
|
||||
if !waitSessionID.MatchString(session) {
|
||||
return "", errors.New("session is required: the id the SessionStart hook named")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// WaitRead is the verb a session reads to see where one wait stands.
|
||||
type WaitRead struct {
|
||||
Address string `json:"address"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// Wait is one answer a session wrote down that it expects (novox/hq ADR 0489 §1-§2). By is the name it was
|
||||
// added under.
|
||||
type Wait struct {
|
||||
ID string `json:"id"`
|
||||
By string `json:"by"`
|
||||
What string `json:"what"`
|
||||
Read WaitRead `json:"read"`
|
||||
Family string `json:"family,omitempty"`
|
||||
Bound int `json:"bound"` // minutes
|
||||
At time.Time `json:"at"`
|
||||
Watcher string `json:"watcher,omitempty"`
|
||||
Said bool `json:"said,omitempty"`
|
||||
}
|
||||
|
||||
// PastBound says whether now is past this wait's bound.
|
||||
func (w Wait) PastBound(now time.Time) bool {
|
||||
return now.Sub(w.At) > time.Duration(w.Bound)*time.Minute
|
||||
}
|
||||
|
||||
// NamedWaits is what the `waits` state keeps for one name on this node (novox/hq ADR 0490 §1): its open
|
||||
// waits, and the binding to whichever session currently answers to that name — empty until the first call
|
||||
// names it, by add or by adopt.
|
||||
type NamedWaits struct {
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node"`
|
||||
CurrentSession string `json:"currentSession,omitempty"`
|
||||
LastCall time.Time `json:"lastCall,omitempty"`
|
||||
Waits []Wait `json:"waits"`
|
||||
}
|
||||
|
||||
// WaitsFile is what one session id's mirrored file holds, whole, on every change (1629/AC3): the open waits
|
||||
// of whichever name is currently bound to this session, and the time they were written.
|
||||
type WaitsFile struct {
|
||||
Session string `json:"session"`
|
||||
At time.Time `json:"at"`
|
||||
Waits []Wait `json:"waits"`
|
||||
}
|
||||
|
||||
// NodeIndexEntry is one name's open waits, as the node-wide index lists it for the SessionStart hook
|
||||
// (novox/hq ADR 0490 §2).
|
||||
type NodeIndexEntry struct {
|
||||
Name string `json:"name"`
|
||||
Open int `json:"open"`
|
||||
CurrentSession string `json:"currentSession,omitempty"`
|
||||
LastCall time.Time `json:"lastCall"`
|
||||
}
|
||||
|
||||
// NodeIndex is the node-wide index file's whole content (novox/hq ADR 0490 §2): every name on this node that
|
||||
// still holds an open wait, written by the tool on every change a name's waits or binding takes, so the
|
||||
// SessionStart hook can say "if this is you, adopt" without calling back into this module.
|
||||
type NodeIndex struct {
|
||||
Node string `json:"node"`
|
||||
At time.Time `json:"at"`
|
||||
Names []NodeIndexEntry `json:"names"`
|
||||
}
|
||||
|
||||
// WaitStore is the `waits` state: a value with its revision, created only when absent, updated only at the
|
||||
// revision read; Keys lists every name kept, for the node-wide index.
|
||||
type WaitStore interface {
|
||||
Get(key string) (json.RawMessage, uint64, bool, error)
|
||||
Create(key string, value any) error
|
||||
Update(key string, value any, revision uint64) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// keptWaits is the `waits` state on the bus.
|
||||
type keptWaits struct{ s stdio.KeptState }
|
||||
|
||||
func (k keptWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
e, err := k.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value, e.Revision, true, nil
|
||||
}
|
||||
func (k keptWaits) Create(key string, value any) error {
|
||||
_, err := k.s.Create(key, value)
|
||||
return err
|
||||
}
|
||||
func (k keptWaits) Update(key string, value any, revision uint64) error {
|
||||
_, err := k.s.Update(key, value, revision)
|
||||
return err
|
||||
}
|
||||
func (k keptWaits) Keys() ([]string, error) { return k.s.Keys() }
|
||||
|
||||
// Waiting is what claude_code_wait reaches: the `waits` state and the clock. Nil outside a launch.
|
||||
type Waiting struct {
|
||||
Store WaitStore
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
var waiting *Waiting
|
||||
|
||||
// waitsKey is the state's key for one name's waits: its name and this node (novox/hq ADR 0490 §1), so two
|
||||
// nodes never collide over the same name.
|
||||
func waitsKey(p Paths, name string) string { return p.Node + "/" + name }
|
||||
|
||||
// WaitsDirPath is where this node's sessions' waits are mirrored, under the agent's home: the directory the
|
||||
// plugin's SessionStart and Stop hooks (1744, 1743) are given on their command line.
|
||||
func WaitsDirPath(p Paths) string {
|
||||
return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir))
|
||||
}
|
||||
|
||||
// waitsFilePath is where one session id's mirrored waits are.
|
||||
func waitsFilePath(p Paths, session string) string {
|
||||
return filepath.Join(WaitsDirPath(p), session+".json")
|
||||
}
|
||||
|
||||
// nodeIndexPath is the node-wide index's file, beside the per-session ones; its name can never collide with
|
||||
// a session id, since waitSessionID never allows a leading "_".
|
||||
func nodeIndexPath(p Paths) string { return filepath.Join(WaitsDirPath(p), "_index.json") }
|
||||
|
||||
func newWaitID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return WaitPrefix + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// addressShaped says whether s could be an address `mesh_call` takes: not blank, no whitespace, and a dot
|
||||
// between two names (`<seat>.<verb>`, `<node>/<seat>.<verb>`, `<node>/<module>.<tool>`).
|
||||
func addressShaped(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || strings.ContainsAny(s, " \t\n\r") {
|
||||
return false
|
||||
}
|
||||
dot := strings.LastIndex(s, ".")
|
||||
return dot > 0 && dot < len(s)-1
|
||||
}
|
||||
|
||||
func readNamedWaits(store WaitStore, key string) (NamedWaits, uint64, bool, error) {
|
||||
raw, rev, found, err := store.Get(key)
|
||||
if err != nil || !found {
|
||||
return NamedWaits{}, 0, found, err
|
||||
}
|
||||
var nw NamedWaits
|
||||
_ = json.Unmarshal(raw, &nw)
|
||||
return nw, rev, true, nil
|
||||
}
|
||||
|
||||
// updateNamedWaits reads one name's waits, lets mutate change them, and writes the result back at the
|
||||
// revision read — created when there was none yet — retried when another write won the race.
|
||||
func updateNamedWaits(store WaitStore, key string, mutate func(NamedWaits) (NamedWaits, error)) (NamedWaits, error) {
|
||||
for i := 0; i < mostWaitAttempts; i++ {
|
||||
nw, rev, found, err := readNamedWaits(store, key)
|
||||
if err != nil {
|
||||
return NamedWaits{}, fmt.Errorf("the wait list could not be read: %w", err)
|
||||
}
|
||||
next, err := mutate(nw)
|
||||
if err != nil {
|
||||
return NamedWaits{}, err
|
||||
}
|
||||
if !found {
|
||||
err = store.Create(key, next)
|
||||
} else {
|
||||
err = store.Update(key, next, rev)
|
||||
}
|
||||
if err == nil {
|
||||
return next, nil
|
||||
}
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return NamedWaits{}, fmt.Errorf("the wait list could not be written: %w", err)
|
||||
}
|
||||
return NamedWaits{}, errors.New("the wait list kept changing elsewhere; try again")
|
||||
}
|
||||
|
||||
// tempSuffix is a short random name for a temp file, so two writes racing for the same path never share one
|
||||
// (review round 2, item a): each gets its own, and only the rename is a single atomic step.
|
||||
func tempSuffix() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// writeSessionFile rewrites one session id's mirrored file whole, atomically (its own temp file, then
|
||||
// rename): it holds exactly the open waits given and the time of this write (1629/AC3). waits is always the
|
||||
// union of every name currently bound to sessionID (sessionWaitsUnion), never only the name an op acted on:
|
||||
// a second name bound to the same id must stay enforced (ADR 0490 §1, review round 2 item 2).
|
||||
func writeSessionFile(p Paths, sessionID string, waits []Wait, now time.Time) error {
|
||||
path := waitsFilePath(p, sessionID)
|
||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if waits == nil {
|
||||
waits = []Wait{}
|
||||
}
|
||||
raw, err := indented(WaitsFile{Session: sessionID, At: now.UTC(), Waits: waits})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-tmp-" + tempSuffix()
|
||||
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// sessionWaitsUnion is every open wait of every name on this node currently bound to session (ADR 0490 §1):
|
||||
// what that session id's mirrored file must hold, whole — never only the one name an op just acted on, or a
|
||||
// second name bound to the same id would stop being enforced the moment any op touched the first.
|
||||
func sessionWaitsUnion(store WaitStore, node, session string) ([]Wait, error) {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := node + "/"
|
||||
var out []Wait
|
||||
names := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
names = append(names, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(names) // deterministic order: tests, and a session reading its own file, see the same thing
|
||||
for _, k := range names {
|
||||
nw, _, found, err := readNamedWaits(store, k)
|
||||
if err != nil || !found || nw.CurrentSession != session {
|
||||
continue
|
||||
}
|
||||
out = append(out, nw.Waits...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeSessionWaits rewrites session's mirrored file with sessionWaitsUnion: every call that changes a
|
||||
// name's waits or its binding goes through this, never writeSessionFile directly with one name's waits
|
||||
// alone.
|
||||
func writeSessionWaits(p Paths, store WaitStore, session string, now time.Time) error {
|
||||
union, err := sessionWaitsUnion(store, p.Node, session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the session's other names could not be read: %w", err)
|
||||
}
|
||||
return writeSessionFile(p, session, union, now)
|
||||
}
|
||||
|
||||
// writeNodeIndex rewrites the node-wide index whole, atomically, from every name's current state on this
|
||||
// node (novox/hq ADR 0490 §2): a full scan of the `waits` state, bounded by how many names are ever used on
|
||||
// one node at once. Best-effort for its caller: a name's own file is already correct by the time this runs.
|
||||
func writeNodeIndex(p Paths, store WaitStore, now time.Time) error {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the node-wide wait index could not be read: %w", err)
|
||||
}
|
||||
prefix := p.Node + "/"
|
||||
idx := NodeIndex{Node: p.Node, At: now.UTC(), Names: []NodeIndexEntry{}}
|
||||
for _, k := range keys {
|
||||
name, ok := strings.CutPrefix(k, prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
nw, _, found, err := readNamedWaits(store, k)
|
||||
if err != nil || !found || len(nw.Waits) == 0 {
|
||||
continue
|
||||
}
|
||||
idx.Names = append(idx.Names, NodeIndexEntry{Name: name, Open: len(nw.Waits),
|
||||
CurrentSession: nw.CurrentSession, LastCall: nw.LastCall.UTC()})
|
||||
}
|
||||
sort.Slice(idx.Names, func(i, j int) bool { return idx.Names[i].Name < idx.Names[j].Name })
|
||||
path := nodeIndexPath(p)
|
||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := indented(idx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-tmp-" + tempSuffix()
|
||||
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// callerName reads `by` as claude_code_ask does: who@node, where node is this one — the name a wait list is
|
||||
// kept under (novox/hq ADR 0490 §1), never the session id.
|
||||
func callerName(p Paths, by string) (string, error) {
|
||||
by = strings.TrimSpace(by)
|
||||
if who, node, found := strings.Cut(by, "@"); found {
|
||||
if node != p.Node {
|
||||
return "", fmt.Errorf("by names the node %q, and this is %s: call on the node the session runs on", node, p.Node)
|
||||
}
|
||||
return who, nil
|
||||
}
|
||||
if by == "" {
|
||||
return "", errors.New("by is required: your session's name, as who@node or who")
|
||||
}
|
||||
return by, nil
|
||||
}
|
||||
|
||||
// bindCaller checks the binding for name against session, refusing when another, more recently bound
|
||||
// session holds it (novox/hq ADR 0490 §3) — naming that session and when it last called, and how to take the
|
||||
// name back with adopt — and otherwise (re)binds it to session at now, so a name nothing has bound yet is
|
||||
// claimed by the first session that calls for it, add included.
|
||||
func bindCaller(nw *NamedWaits, name, node, by, session string, now time.Time) error {
|
||||
if nw.CurrentSession != "" && nw.CurrentSession != session {
|
||||
return fmt.Errorf("the name %q now answers to session %s (it last called at %s); adopt it again if this "+
|
||||
"is you: claude_code_wait {\"op\":\"adopt\",\"by\":%q,\"session\":%q}", name, nw.CurrentSession,
|
||||
nw.LastCall.UTC().Format(time.RFC3339), by, session)
|
||||
}
|
||||
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, node, session, now.UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// findWait is the index of id in waits, or -1.
|
||||
func findWait(waits []Wait, id string) int {
|
||||
for i, w := range waits {
|
||||
if w.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// WaitInput is what claude_code_wait's add takes.
|
||||
type WaitInput struct {
|
||||
Session string
|
||||
By string
|
||||
What string
|
||||
Read WaitRead
|
||||
Family string
|
||||
Bound int
|
||||
}
|
||||
|
||||
// AddWait keeps one more wait for a name, refusing outside ADR 0489's bounds (1629/AC5) or when another
|
||||
// session has since taken the name over (ADR 0490 §3), and rewrites the caller's mirrored file (1629/AC3).
|
||||
func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(in.Session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, in.By)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
what := strings.TrimSpace(in.What)
|
||||
if what == "" {
|
||||
return nil, errors.New("what is required: say what you are waiting for")
|
||||
}
|
||||
if in.Bound < ShortestWaitBound || in.Bound > LongestWaitBound {
|
||||
return nil, fmt.Errorf("bound_minutes is %d; it is %d to %d", in.Bound, ShortestWaitBound, LongestWaitBound)
|
||||
}
|
||||
address := strings.TrimSpace(in.Read.Address)
|
||||
if !addressShaped(address) {
|
||||
return nil, fmt.Errorf("read.address %q is not an address: the verb that reads where this wait stands, as mesh_call takes it", in.Read.Address)
|
||||
}
|
||||
w := Wait{ID: newWaitID(), By: name, What: what, Read: WaitRead{Address: address, Arguments: in.Read.Arguments},
|
||||
Family: strings.TrimSpace(in.Family), Bound: in.Bound, At: now.UTC()}
|
||||
key := waitsKey(p, name)
|
||||
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, in.By, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
if len(nw.Waits) >= MostWaits {
|
||||
return nw, fmt.Errorf("%d waits already wait for %s; at most %d open waits per name", len(nw.Waits), name, MostWaits)
|
||||
}
|
||||
nw.Waits = append(append([]Wait(nil), nw.Waits...), w)
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was kept, but its file could not be written: %w", err)
|
||||
}
|
||||
if err := writeNodeIndex(p, store, now); err != nil {
|
||||
say("writing the node-wide wait index after %s added for %s: %v", w.ID, name, err)
|
||||
}
|
||||
say("wait %s added for %s (session %s): %s", w.ID, name, session, what)
|
||||
return map[string]any{"id": w.ID, "name": name, "session": session, "open": len(nw.Waits)}, nil
|
||||
}
|
||||
|
||||
// WatchedWait marks a wait as followed: by mesh_wait on its family, or a watcher started for it (1629/AC2).
|
||||
func WatchedWait(p Paths, session, by, id, watcher string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, watcher = strings.TrimSpace(id), strings.TrimSpace(watcher)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required: the one add answered")
|
||||
}
|
||||
if watcher == "" {
|
||||
return nil, errors.New("watcher is required: what follows this wait (mesh_wait on its family, or a watcher agent)")
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
i := findWait(nw.Waits, id)
|
||||
if i < 0 {
|
||||
return nw, fmt.Errorf("no open wait %s for %s", id, name)
|
||||
}
|
||||
nw.Waits[i].Watcher = watcher
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was watched, but its file could not be written: %w", err)
|
||||
}
|
||||
_ = writeNodeIndex(p, store, now)
|
||||
return map[string]any{"id": id, "watcher": watcher}, nil
|
||||
}
|
||||
|
||||
// DoneWait removes a wait from the open list: it ended, with outcome (1629/AC2).
|
||||
func DoneWait(p Paths, session, by, id, outcome string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, outcome = strings.TrimSpace(id), strings.TrimSpace(outcome)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required: the one add answered")
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
i := findWait(nw.Waits, id)
|
||||
if i < 0 {
|
||||
return nw, fmt.Errorf("no open wait %s for %s", id, name)
|
||||
}
|
||||
nw.Waits = append(append([]Wait(nil), nw.Waits[:i]...), nw.Waits[i+1:]...)
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was done, but its file could not be written: %w", err)
|
||||
}
|
||||
_ = writeNodeIndex(p, store, now)
|
||||
say("wait %s for %s (session %s) done: %s", id, name, session, outcome)
|
||||
return map[string]any{"id": id, "done": true, "outcome": outcome}, nil
|
||||
}
|
||||
|
||||
// SaidWait marks a wait past its bound as told to the operator: refused while it is still within its bound
|
||||
// (1629/AC2).
|
||||
func SaidWait(p Paths, session, by, id string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required: the one add answered")
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
i := findWait(nw.Waits, id)
|
||||
if i < 0 {
|
||||
return nw, fmt.Errorf("no open wait %s for %s", id, name)
|
||||
}
|
||||
if !nw.Waits[i].PastBound(now) {
|
||||
return nw, fmt.Errorf("wait %s is not past its bound yet: said is for telling the operator about one that is", id)
|
||||
}
|
||||
nw.Waits[i].Said = true
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was said, but its file could not be written: %w", err)
|
||||
}
|
||||
_ = writeNodeIndex(p, store, now)
|
||||
return map[string]any{"id": id, "said": true}, nil
|
||||
}
|
||||
|
||||
// ListWaits answers a name's open waits, with their age and whether they are past their bound (1629/AC1),
|
||||
// and who currently holds the name. Read-only (review round 2, item c): unlike every other op it never
|
||||
// claims an unbound name, and never refuses a session that is not (or not yet) the one bound to it — a
|
||||
// session may want to look before adopting.
|
||||
func ListWaits(p Paths, session, by string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nw, _, _, err := readNamedWaits(store, waitsKey(p, name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the wait list could not be read: %w", err)
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, w := range nw.Waits {
|
||||
entry := map[string]any{"id": w.ID, "what": w.What, "by": w.By, "address": w.Read.Address,
|
||||
"bound_minutes": w.Bound, "at": w.At.Format(time.RFC3339), "age_seconds": int(now.Sub(w.At).Seconds()),
|
||||
"past_bound": w.PastBound(now), "watcher": w.Watcher, "said": w.Said}
|
||||
if len(w.Read.Arguments) > 0 {
|
||||
entry["arguments"] = w.Read.Arguments
|
||||
}
|
||||
if w.Family != "" {
|
||||
entry["family"] = w.Family
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
answer := map[string]any{"session": session, "name": name, "open": out}
|
||||
if nw.CurrentSession != "" {
|
||||
answer["held-by"] = map[string]any{"session": nw.CurrentSession, "last-called": nw.LastCall.UTC().Format(time.RFC3339)}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// AdoptName binds name to session, always taking it over from whoever held it (novox/hq ADR 0490 §3): the
|
||||
// mirrored files of both the session it is taken from and the one adopting it are rewritten at once, so a
|
||||
// session that no longer owns a name never finds its old waits in its own file, and the new one does right
|
||||
// away — no waiting on a bound, unlike the Stop and SessionStart hooks' own.
|
||||
func AdoptName(p Paths, by, session string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
var oldSession string
|
||||
var oldLastCall time.Time
|
||||
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
oldSession, oldLastCall = nw.CurrentSession, nw.LastCall
|
||||
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, p.Node, session, now.UTC()
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tookOver := oldSession != "" && oldSession != session
|
||||
if tookOver {
|
||||
// oldSession's file is rewritten from the union of whatever names remain bound to it (ADR 0490 §1,
|
||||
// review round 2 item 2): name no longer is one of them, but another might still be — never simply
|
||||
// emptied.
|
||||
if err := writeSessionWaits(p, store, oldSession, now); err != nil {
|
||||
return nil, fmt.Errorf("%s was adopted, but the session it was taken from could not be told: %w", name, err)
|
||||
}
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the name was adopted, but its file could not be written: %w", err)
|
||||
}
|
||||
if err := writeNodeIndex(p, store, now); err != nil {
|
||||
say("writing the node-wide wait index after %s was adopted: %v", name, err)
|
||||
}
|
||||
// Review round 2, item b: a fresh three-strikes count starts under the new binding, on both ids.
|
||||
waitsDir := WaitsDirPath(p)
|
||||
_ = writeRefusalCounters(waitsDir, session, map[string]int{})
|
||||
if tookOver {
|
||||
_ = writeRefusalCounters(waitsDir, oldSession, map[string]int{})
|
||||
}
|
||||
answer := map[string]any{"name": name, "session": session, "open": len(nw.Waits)}
|
||||
if tookOver {
|
||||
answer["took-over-from"] = map[string]any{"session": oldSession, "last-called": oldLastCall.UTC().Format(time.RFC3339)}
|
||||
say("%s adopted by session %s, taken over from %s (last called %s)", name, session, oldSession,
|
||||
oldLastCall.UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
say("%s adopted by session %s", name, session)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// waitsFileIn is where one session's mirrored waits are, given a hook's own waits-dir argument: the hooks
|
||||
// have no launch environment, only their command line, so they never recompute this from Paths.
|
||||
func waitsFileIn(waitsDir, session string) string { return filepath.Join(waitsDir, session+".json") }
|
||||
|
||||
// nodeIndexPathIn is the node-wide index file, given a hook's own waits-dir argument.
|
||||
func nodeIndexPathIn(waitsDir string) string { return filepath.Join(waitsDir, "_index.json") }
|
||||
|
||||
// readWaitsFile reads one session's mirrored file, as the hooks do: a plain local read, as the session's own
|
||||
// account (never through fsys or the module's state), and never calling back into this module.
|
||||
func readWaitsFile(path string) (WaitsFile, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return WaitsFile{}, err
|
||||
}
|
||||
var f WaitsFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return WaitsFile{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// readNodeIndex reads the node-wide index, as the SessionStart hook does: a plain local read, never through
|
||||
// fsys or the module's state, and never calling back into this module.
|
||||
func readNodeIndex(path string) (NodeIndex, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return NodeIndex{}, err
|
||||
}
|
||||
var idx NodeIndex
|
||||
if err := json.Unmarshal(raw, &idx); err != nil {
|
||||
return NodeIndex{}, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// waitTools is claude_code_wait: add, watched, done, said, list and adopt, over a name's wait list (novox/hq
|
||||
// ADR 0489, ADR 0490).
|
||||
func waitTools(p Paths) []stdio.Tool {
|
||||
notLaunched := errors.New("this module was not launched with the bus; no wait can be kept")
|
||||
return []stdio.Tool{
|
||||
{Name: "claude_code_wait",
|
||||
Description: "Your wait list, kept under your session's name (novox/hq ADR 0489, ADR 0490): what you " +
|
||||
"still expect an answer to — an Approve, a delivery's end, anything you started and must act on when " +
|
||||
"it ends. The plugin's Stop hook refuses to end your turn while a wait here is open and nothing " +
|
||||
"follows it. op is add, watched, done, said, list or adopt; session (your current session id, named " +
|
||||
"by the SessionStart hook) and by (your session's name, as who@node or who) are always required. A " +
|
||||
"name is bound to whichever session last called for it; if your own id changed (a /clear) you must " +
|
||||
"adopt your name again before it answers to you — the SessionStart hook lists every name on this " +
|
||||
"node with an open wait and how. adopt takes the name over at once, however recently another " +
|
||||
"session called for it; that session is told so, naming yours, the next time it calls. add takes " +
|
||||
"what (what you are waiting for), read (the verb that answers where it stands: {address, " +
|
||||
"arguments}), family (optional, the event mesh_wait can wait on) and bound_minutes (1 to 1440), and " +
|
||||
"answers an id; at most 50 waits are open per name. watched takes id and watcher (what follows it: " +
|
||||
"mesh_wait on its family, or a watcher agent you started). done takes id and outcome, and removes it " +
|
||||
"from the open list. said takes id and marks a wait past its bound as told to the operator — " +
|
||||
"refused while it is still within its bound. list answers the open waits, with their age and " +
|
||||
"whether each is past its bound.",
|
||||
Input: map[string]any{
|
||||
"op": str("add, watched, done, said, list or adopt"),
|
||||
"session": str("required: your session's current id"),
|
||||
"by": str("required: your session's name, as who@node or who"),
|
||||
"what": str("add: what you are waiting for, in plain words"),
|
||||
"read": map[string]any{"type": "object", "description": "add: the verb that reads where this wait stands",
|
||||
"properties": map[string]any{
|
||||
"address": str("the address, as mesh_call takes it"),
|
||||
"arguments": map[string]any{"type": "object", "description": "its arguments"},
|
||||
}},
|
||||
"family": str("add: the event family mesh_wait can wait on, when there is one"),
|
||||
"bound_minutes": map[string]any{"type": "integer", "description": "add: minutes until this wait's bound, 1 to 1440"},
|
||||
"id": str("watched, done or said: the id add answered"),
|
||||
"watcher": str("watched: what follows this wait"),
|
||||
"outcome": str("done: what the wait ended with"),
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if waiting == nil {
|
||||
return nil, notLaunched
|
||||
}
|
||||
session, by := strArg(a, "session"), strArg(a, "by")
|
||||
now := time.Now()
|
||||
if waiting.Now != nil {
|
||||
now = waiting.Now()
|
||||
}
|
||||
switch op := strArg(a, "op"); op {
|
||||
case "add":
|
||||
read := WaitRead{}
|
||||
if m, ok := a["read"].(map[string]any); ok {
|
||||
read.Address = strArg(m, "address")
|
||||
if args, ok := m["arguments"].(map[string]any); ok {
|
||||
read.Arguments = args
|
||||
}
|
||||
}
|
||||
bound := 0
|
||||
if n, ok := a["bound_minutes"].(float64); ok {
|
||||
bound = int(n)
|
||||
}
|
||||
return AddWait(p, WaitInput{Session: session, By: by, What: strArg(a, "what"), Read: read,
|
||||
Family: strArg(a, "family"), Bound: bound}, waiting.Store, now)
|
||||
case "watched":
|
||||
return WatchedWait(p, session, by, strArg(a, "id"), strArg(a, "watcher"), waiting.Store, now)
|
||||
case "done":
|
||||
return DoneWait(p, session, by, strArg(a, "id"), strArg(a, "outcome"), waiting.Store, now)
|
||||
case "said":
|
||||
return SaidWait(p, session, by, strArg(a, "id"), waiting.Store, now)
|
||||
case "list":
|
||||
return ListWaits(p, session, by, waiting.Store, now)
|
||||
case "adopt":
|
||||
return AdoptName(p, by, session, waiting.Store, now)
|
||||
default:
|
||||
return nil, fmt.Errorf("op is add, watched, done, said, list or adopt, not %q", op)
|
||||
}
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package main
|
||||
|
||||
// A session's wait list (novox/hq ADR 0489, ADR 0490, issues 1629, 1744).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// memWaits is the `waits` state: values with revisions, create-only and compare-and-set, as the bus keeps it.
|
||||
type memWaits struct {
|
||||
mu sync.Mutex
|
||||
m map[string]json.RawMessage
|
||||
rev map[string]uint64
|
||||
n uint64
|
||||
}
|
||||
|
||||
func newMemWaits() *memWaits {
|
||||
return &memWaits{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
|
||||
}
|
||||
|
||||
func (s *memWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
raw, ok := s.m[key]
|
||||
return raw, s.rev[key], ok, nil
|
||||
}
|
||||
func (s *memWaits) put(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
s.n++
|
||||
s.m[key], s.rev[key] = raw, s.n
|
||||
return err
|
||||
}
|
||||
func (s *memWaits) Create(key string, value any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.m[key]; ok {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.put(key, value)
|
||||
}
|
||||
func (s *memWaits) Update(key string, value any, revision uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.rev[key] != revision {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.put(key, value)
|
||||
}
|
||||
func (s *memWaits) Keys() ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, 0, len(s.m))
|
||||
for k := range s.m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addApproveWait adds the wait fixture the operator's Approve is kept under, for name on session.
|
||||
func addApproveWait(t *testing.T, p Paths, store WaitStore, name, session string, at time.Time) string {
|
||||
t.Helper()
|
||||
out, err := AddWait(p, WaitInput{Session: session, By: name + "@" + p.Node, What: "the operator's Approve of pull request 42",
|
||||
Read: WaitRead{Address: "mesh-delivery.recheck", Arguments: map[string]any{"pr": 42}}, Family: "mesh-delivery.decided",
|
||||
Bound: 10}, store, at)
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
return out["id"].(string)
|
||||
}
|
||||
|
||||
// 1629/AC1: add answers an id; list answers the open waits with their age and whether each is past its bound.
|
||||
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0)
|
||||
if !strings.HasPrefix(id, WaitPrefix) {
|
||||
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
|
||||
}
|
||||
out, err := ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open := out["open"].([]map[string]any)
|
||||
if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 {
|
||||
t.Fatalf("%+v", open)
|
||||
}
|
||||
out, err = ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(11*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open = out["open"].([]map[string]any)
|
||||
if open[0]["past_bound"] != true {
|
||||
t.Fatalf("past its 10-minute bound after 11 minutes: %+v", open[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC2: watched, done and said change the wait; said is refused for a wait not past its bound; done removes
|
||||
// it from the open list.
|
||||
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0)
|
||||
|
||||
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") {
|
||||
t.Fatalf("said before the bound: %v", err)
|
||||
}
|
||||
if _, err := WatchedWait(p, "sess-2", by, id, "mesh_wait on mesh-delivery.decided", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := ListWaits(p, "sess-2", by, store, t0.Add(2*time.Minute))
|
||||
open := out["open"].([]map[string]any)
|
||||
if open[0]["watcher"] != "mesh_wait on mesh-delivery.decided" {
|
||||
t.Fatalf("watcher not kept: %+v", open[0])
|
||||
}
|
||||
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(11*time.Minute)); err != nil {
|
||||
t.Fatalf("said past the bound: %v", err)
|
||||
}
|
||||
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(12*time.Minute))
|
||||
open = out["open"].([]map[string]any)
|
||||
if open[0]["said"] != true {
|
||||
t.Fatalf("said not kept: %+v", open[0])
|
||||
}
|
||||
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(13*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(14*time.Minute))
|
||||
if len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("done did not remove it from the open list: %v", out)
|
||||
}
|
||||
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(15*time.Minute)); err == nil {
|
||||
t.Fatal("done on an already-done wait was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
|
||||
// and the write time.
|
||||
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0)
|
||||
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Session != "sess-3" || len(f.Waits) != 1 || f.Waits[0].ID != id1 || !f.At.Equal(t0.UTC()) {
|
||||
t.Fatalf("%+v", f)
|
||||
}
|
||||
// Written whole: no .mesh-tmp left beside it.
|
||||
if _, err := os.Stat(waitsFilePath(p, "sess-3") + ".mesh-tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("a temp file was left beside it: %v", err)
|
||||
}
|
||||
id2, err := AddWait(p, WaitInput{Session: "sess-3", By: by, What: "a second wait",
|
||||
Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 5}, store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil || len(f.Waits) != 2 || !f.At.Equal(t0.Add(time.Minute).UTC()) {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
if _, err := DoneWait(p, "sess-3", by, id1, "approved", store, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil || len(f.Waits) != 1 || f.Waits[0].ID != id2["id"] {
|
||||
t.Fatalf("the file does not hold exactly the open waits: %+v %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same name
|
||||
// after a /clear that keeps calling with the same session id; a different name lists none of them.
|
||||
func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-4", t0)
|
||||
|
||||
// A restart: a fresh Waiting over the same store reads the same waits.
|
||||
restarted := &Waiting{Store: store, Now: func() time.Time { return t0.Add(time.Minute) }}
|
||||
_ = restarted
|
||||
out, err := ListWaits(p, "sess-4", by, store, t0.Add(time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("not read again after a restart: %v %v", out, err)
|
||||
}
|
||||
|
||||
// The same session id, called again: still lists it.
|
||||
out, err = ListWaits(p, "sess-4", by, store, t0.Add(2*time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("not read again for the same name: %v %v", out, err)
|
||||
}
|
||||
|
||||
// A different name, same node: none of them.
|
||||
out, err = ListWaits(p, "sess-other", "fix-other@g14", store, t0.Add(2*time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("another name saw them: %v %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC5: add refuses an empty what, a bound under 1 or over 1440 minutes, a read address that is not an
|
||||
// address, and more than 50 open waits per name, naming the argument.
|
||||
func TestAddRefusesOutsideItsBounds(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
base := func() WaitInput {
|
||||
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
|
||||
}
|
||||
for name, change := range map[string]struct {
|
||||
mutate func(*WaitInput)
|
||||
want string
|
||||
}{
|
||||
"empty what": {func(in *WaitInput) { in.What = "" }, "what is required"},
|
||||
"bound zero": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
||||
"bound under 1": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
||||
"bound over 1440": {func(in *WaitInput) { in.Bound = 1441 }, "bound_minutes"},
|
||||
"empty address": {func(in *WaitInput) { in.Read.Address = "" }, "read.address"},
|
||||
"address no dot": {func(in *WaitInput) { in.Read.Address = "notanaddress" }, "read.address"},
|
||||
"address has spaces": {func(in *WaitInput) { in.Read.Address = "not an address" }, "read.address"},
|
||||
} {
|
||||
in := base()
|
||||
change.mutate(&in)
|
||||
store := newMemWaits()
|
||||
if _, err := AddWait(p, in, store, t0); err == nil || !strings.Contains(err.Error(), change.want) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// More than 50 open waits per name.
|
||||
store := newMemWaits()
|
||||
for i := 0; i < MostWaits; i++ {
|
||||
if _, err := AddWait(p, base(), store, t0); err != nil {
|
||||
t.Fatalf("wait %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := AddWait(p, base(), store, t0); err == nil || !strings.Contains(err.Error(), "at most 50 open waits") {
|
||||
t.Fatalf("the 51st wait was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1 item (b): list, watched, done and said refuse a session id that is not a plain one (the
|
||||
// same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key.
|
||||
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
by := "fix-1629@g14"
|
||||
for _, bad := range []string{"", "a/b", "../x"} {
|
||||
store := newMemWaits()
|
||||
if _, err := ListWaits(p, bad, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("list %q: %v", bad, err)
|
||||
}
|
||||
if _, err := WatchedWait(p, bad, by, "w-1", "watcher", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("watched %q: %v", bad, err)
|
||||
}
|
||||
if _, err := DoneWait(p, bad, by, "w-1", "outcome", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("done %q: %v", bad, err)
|
||||
}
|
||||
if _, err := SaidWait(p, bad, by, "w-1", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("said %q: %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC6: the tool is served through the module's own dispatch.
|
||||
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
var added map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &added); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ := added["id"].(string)
|
||||
if id == "" {
|
||||
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"})
|
||||
if isErr || !strings.Contains(text, id) {
|
||||
t.Fatalf("list: %v %s", isErr, text)
|
||||
}
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@g14",
|
||||
"id": id, "outcome": "approved"})
|
||||
if isErr {
|
||||
t.Fatalf("done: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ADR 0490: a name's waits survive a session's id changing, by adopting -----------------------------
|
||||
|
||||
// ADR 0490 §1: a name nothing has bound yet is claimed by the first session that calls for it (add), without
|
||||
// a separate adopt first.
|
||||
func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0)
|
||||
out, err := ListWaits(p, "sess-a", "fix-1629@g14", store, t0)
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id {
|
||||
t.Fatalf("%v %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §1/§3: once a name is bound to a session, a different, non-adopted session calling for the same
|
||||
// name (any op) is refused, naming the session that holds it and when it last called.
|
||||
func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
|
||||
if _, err := AddWait(p, WaitInput{Session: "sess-new", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
Bound: 10}, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "sess-old") {
|
||||
t.Fatalf("add from a different session was not refused: %v", err)
|
||||
}
|
||||
|
||||
// list, read-only (review round 2 item c), never refuses: it just says who holds the name.
|
||||
out, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("list from a different session was refused: %v", err)
|
||||
}
|
||||
held, _ := out["held-by"].(map[string]any)
|
||||
if held == nil || held["session"] != "sess-old" {
|
||||
t.Fatalf("list did not say who holds the name: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §2/§3: adopt right after a simulated /clear binds the name to the new session id at once; the
|
||||
// mirrored file of the old session id is emptied and the new one holds the waits, with no delay.
|
||||
func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
|
||||
out, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["session"] != "sess-new" || out["open"] != 1 {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
took, _ := out["took-over-from"].(map[string]any)
|
||||
if took == nil || took["session"] != "sess-old" {
|
||||
t.Fatalf("the answer did not name the earlier id: %v", out)
|
||||
}
|
||||
|
||||
oldFile, err := readWaitsFile(waitsFilePath(p, "sess-old"))
|
||||
if err != nil || len(oldFile.Waits) != 0 {
|
||||
t.Fatalf("the old session's file still holds waits: %+v %v", oldFile, err)
|
||||
}
|
||||
newFile, err := readWaitsFile(waitsFilePath(p, "sess-new"))
|
||||
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != id {
|
||||
t.Fatalf("the new session's file does not hold the waits: %+v %v", newFile, err)
|
||||
}
|
||||
|
||||
// The new session can act on the name at once: no 10-minute rule.
|
||||
if _, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("the new session could not act right after adopting: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §3: adopt of a name bound to another id always takes it over, however recently that id called —
|
||||
// no bound protects it.
|
||||
func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0)
|
||||
// sess-fresh calls again right before being adopted away from.
|
||||
if _, err := ListWaits(p, "sess-fresh", by, store, t0.Add(10*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := AdoptName(p, by, "sess-taker", store, t0.Add(11*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("adopt was refused even though it always takes over: %v", err)
|
||||
}
|
||||
if out["session"] != "sess-taker" {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §3: the earlier id's next claude_code_wait call (any op) answers that its name now answers to
|
||||
// another session, naming it.
|
||||
func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := AddWait(p, WaitInput{Session: "sess-old", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
Bound: 10}, store, t0.Add(2*time.Minute))
|
||||
if err == nil || !strings.Contains(err.Error(), "sess-new") || !strings.Contains(err.Error(), "adopt") {
|
||||
t.Fatalf("the earlier id's next call did not name the new session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §2: the node-wide index lists every name on this node with an open wait, for the SessionStart
|
||||
// hook to read directly.
|
||||
func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-x", t0)
|
||||
addApproveWait(t, p, store, "fix-1744", "sess-y", t0)
|
||||
if _, err := DoneWait(p, "sess-x", "fix-1629@g14", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
|
||||
store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx, err := readNodeIndex(nodeIndexPath(p))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx.Node != "g14" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
|
||||
idx.Names[0].CurrentSession != "sess-y" {
|
||||
t.Fatalf("%+v", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// wantsOnlyWait is the id of the one wait a fixture added, read back through list.
|
||||
func wantsOnlyWait(t *testing.T, store WaitStore, p Paths, name, session string) string {
|
||||
t.Helper()
|
||||
out, err := ListWaits(p, session, name+"@"+p.Node, store, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open := out["open"].([]map[string]any)
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("%v", open)
|
||||
}
|
||||
return open[0]["id"].(string)
|
||||
}
|
||||
|
||||
// claude_code_wait op=adopt, served through the module's own dispatch (1629/AC6, ADR 0490).
|
||||
func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@g14"})
|
||||
if isErr || !strings.Contains(text, "sess-old") {
|
||||
t.Fatalf("adopt: %v %s", isErr, text)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Review round 2: a session id's mirrored file is the union of every name bound to it -----------------
|
||||
|
||||
// Review round 2, item 2 (ADR 0490 §1): one session id with two different names bound to it holds both
|
||||
// names' waits in its one mirrored file, not only the name an op last acted on.
|
||||
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
||||
|
||||
f, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, w := range f.Waits {
|
||||
ids[w.ID] = true
|
||||
}
|
||||
if len(f.Waits) != 2 || !ids[idX] || !ids[idY] {
|
||||
t.Fatalf("the session's file does not hold both names' waits: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item 2: adopting one of two names bound to the same session away keeps that session's
|
||||
// file holding the other name's waits — it is never emptied wholesale.
|
||||
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
||||
|
||||
if _, err := AdoptName(p, "fix-y@g14", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sharedFile, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
|
||||
if err != nil || len(sharedFile.Waits) != 1 || sharedFile.Waits[0].ID != idX {
|
||||
t.Fatalf("sess-shared should still hold fix-x's wait: %+v %v", sharedFile, err)
|
||||
}
|
||||
newFile, err := readWaitsFile(waitsFilePath(p, "sess-y-new"))
|
||||
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != idY {
|
||||
t.Fatalf("sess-y-new should hold fix-y's wait: %+v %v", newFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item b: adopt clears the refusal counter of both the earlier id and the new one, so a
|
||||
// fresh three-strikes count starts under the new binding.
|
||||
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
waitsDir := WaitsDirPath(p)
|
||||
if err := writeRefusalCounters(waitsDir, "sess-old", map[string]int{"w-1": 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeRefusalCounters(waitsDir, "sess-new", map[string]int{"w-2": 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := readRefusalCounters(waitsDir, "sess-old"); len(c) != 0 {
|
||||
t.Errorf("sess-old's counter was not cleared: %v", c)
|
||||
}
|
||||
if c := readRefusalCounters(waitsDir, "sess-new"); len(c) != 0 {
|
||||
t.Errorf("sess-new's counter was not cleared: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item c: list is read-only — it never binds an unbound name, and never refuses a
|
||||
// mismatched session; it answers the name's waits and who currently holds it.
|
||||
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
|
||||
// An unbound name: list answers no waits, and binds nothing (a later add from a different session still
|
||||
// succeeds, as the first caller for an unbound name).
|
||||
out, err := ListWaits(p, "sess-reader", by, store, t0)
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("an unbound name: %v %v", out, err)
|
||||
}
|
||||
if _, found, _ := readSessionBinding(store, waitsKey(p, "fix-1629")); found {
|
||||
t.Fatal("list bound an unbound name")
|
||||
}
|
||||
if _, err := AddWait(p, WaitInput{Session: "sess-first", By: by, What: "x", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
Bound: 10}, store, t0); err != nil {
|
||||
t.Fatalf("add after a read-only list: %v", err)
|
||||
}
|
||||
|
||||
// A name bound to another session: list still answers (never refused), and says who holds it.
|
||||
out, err = ListWaits(p, "sess-other", by, store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("list for a name held by another session was refused: %v", err)
|
||||
}
|
||||
if len(out["open"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("list did not answer the name's waits: %v", out)
|
||||
}
|
||||
held, _ := out["held-by"].(map[string]any)
|
||||
if held == nil || held["session"] != "sess-first" {
|
||||
t.Fatalf("list did not say who holds the name: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// readSessionBinding is a thin test helper reading the raw NamedWaits at key, without claiming it.
|
||||
func readSessionBinding(store WaitStore, key string) (NamedWaits, bool, error) {
|
||||
nw, _, found, err := readNamedWaits(store, key)
|
||||
return nw, found, err
|
||||
}
|
||||
@@ -28,7 +28,8 @@
|
||||
"config",
|
||||
"proposals",
|
||||
"questions",
|
||||
"memory"
|
||||
"memory",
|
||||
"waits"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
@@ -67,6 +68,7 @@
|
||||
"claude_code_proposals",
|
||||
"claude_code_ask",
|
||||
"claude_code_answer",
|
||||
"claude_code_wait",
|
||||
"claude_code_settings_get",
|
||||
"claude_code_settings_set",
|
||||
"claude_code_settings_clear",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
@@ -170,20 +171,33 @@ func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T)
|
||||
store := dir + "/cm/clipmenu.6.op"
|
||||
os.MkdirAll(store, 0o700)
|
||||
lock := store + "/session_lock"
|
||||
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed
|
||||
holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60")
|
||||
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed. It says "held" once it has the
|
||||
// lock, so the test goes on only when the lock is held for certain: a holder that is slow to start (the checker
|
||||
// runs thousands of tests at once) must not let the launcher find the lock free (issue 1814).
|
||||
holder := exec.Command("flock", "-x", "-n", lock, "sh", "-c", "echo held; exec sleep 60")
|
||||
holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
held, err := holder.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := holder.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// flock's child shares the lock, so the whole group goes
|
||||
release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() }
|
||||
defer release()
|
||||
for i := 0; i < 50; i++ {
|
||||
if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil {
|
||||
break
|
||||
said := make(chan string, 1)
|
||||
go func() {
|
||||
line, _ := bufio.NewReader(held).ReadString('\n')
|
||||
said <- strings.TrimSpace(line)
|
||||
}()
|
||||
select {
|
||||
case line := <-said:
|
||||
if line != "held" {
|
||||
t.Fatalf("the older clipmenud did not take the lock: %q", line)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("the older clipmenud did not take the lock within 30 s")
|
||||
}
|
||||
var out syncBuffer
|
||||
cmd := exec.Command("sh", file)
|
||||
|
||||
@@ -5,16 +5,16 @@ machine: an agent working on a change builds it and runs its tests there, with t
|
||||
pull request. The lab is the QA step after that: it tests the work in a sandbox, through its seat at the mesh
|
||||
scope, and is not where development happens.
|
||||
|
||||
Assign it to every node.
|
||||
Assign it to every node, with the `go` and `nodejs` modules: it requires their provisions `go-toolchain` and `node-toolchain` (novox/hq ADR 0477 clause 4).
|
||||
|
||||
## Owns
|
||||
|
||||
| what | package | class (ADR 0182) |
|
||||
|---|---|---|
|
||||
| the Go toolchain, for `go build` and `go test` in every Go module | `go` | package |
|
||||
| the Go toolchain, for `go build` and `go test` in every Go module | the `go` module's package `go` | package |
|
||||
| a C compiler, for `go test -race` and cgo | `gcc` | package |
|
||||
| make | `make` | package |
|
||||
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | `nodejs`, `npm` | package |
|
||||
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | the `nodejs` module's packages `nodejs`, `npm` | package |
|
||||
| Python, for the scripts some checks run | `python` | package |
|
||||
| git and jq | `git`, `jq` | package |
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"go-toolchain",
|
||||
"node-toolchain"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
},
|
||||
{
|
||||
"id": "c-compiler",
|
||||
"type": "package",
|
||||
@@ -20,16 +19,6 @@
|
||||
"type": "package",
|
||||
"package": "make"
|
||||
},
|
||||
{
|
||||
"id": "nodejs",
|
||||
"type": "package",
|
||||
"package": "nodejs"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"type": "package",
|
||||
|
||||
@@ -24,7 +24,7 @@ machine's resources a seat, these tools become its verbs.
|
||||
|
||||
## Tools
|
||||
|
||||
All five only read.
|
||||
All six only read.
|
||||
|
||||
| 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_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_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` |
|
||||
|
||||
### How each number is read
|
||||
@@ -87,4 +88,5 @@ cover:
|
||||
- processes ranked, one that started inside the window left out, unreadable ones counted;
|
||||
- filesystems said once, mount escapes undone, a statfs that fails said;
|
||||
- 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.
|
||||
|
||||
@@ -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"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -53,6 +54,13 @@ type Machine struct {
|
||||
Run Runner
|
||||
Wait func(time.Duration)
|
||||
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.
|
||||
|
||||
@@ -136,6 +136,34 @@ func Tools(m *Machine) []stdio.Tool {
|
||||
}, 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",
|
||||
Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " +
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"disk_load",
|
||||
"disk_top",
|
||||
"disk_filesystems",
|
||||
"disk_folders",
|
||||
"disk_devices",
|
||||
"cpu_top"
|
||||
],
|
||||
|
||||
@@ -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_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_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_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 |
|
||||
|
||||
@@ -944,16 +944,9 @@ func (c *Client) Volumes(ctx context.Context, unmountedOnly, sizes bool) (map[st
|
||||
}
|
||||
size := map[string]string{}
|
||||
if sizes {
|
||||
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
|
||||
if err != nil {
|
||||
if size, err = c.volumeSizes(ctx); 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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
vols := []map[string]any{}
|
||||
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"
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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))
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
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; " +
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"docker_disk_usage",
|
||||
"docker_networks",
|
||||
"docker_volumes",
|
||||
"docker_volumes_remove_anonymous",
|
||||
"docker_events",
|
||||
"docker_daemon_config",
|
||||
"docker_unlabelled",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891).
|
||||
*.js
|
||||
+15
-9
@@ -22,16 +22,18 @@ Every new head of an open pull request is announced as `pull.updated`, once, and
|
||||
minutes, or when the search fails.
|
||||
- **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
|
||||
open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
|
||||
`mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
|
||||
`mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
|
||||
delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` (or hq's
|
||||
`mesh/repo-check`) with none of them for ten minutes since it first saw it so is a delivery held past its bound
|
||||
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
|
||||
Each of the head's statuses comes with its state and when it was set, so a head whose check started and never
|
||||
answered — a required check pending, or never set beside one that is — is said as a stalled line in the state
|
||||
`unanswered` once that has lasted the delivery table's bound for a check asked and not answered, an hour, counted
|
||||
from its first sight and again only when a check is asked anew (issue 438).
|
||||
- **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own
|
||||
was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep
|
||||
at the latest, and `mesh-controller.conditions` names none for it.
|
||||
was one, and the announcer it fixes never saw it — is announced within a pass of the next sweep at the latest, its
|
||||
`mesh/check` is the checker's to set, and `mesh-controller.conditions` names none for it. (Since issue 1301 this
|
||||
module writes `mesh/merge-gate` only where a base still requires it, and none does once the forge requires
|
||||
`mesh/check`.)
|
||||
- **A pull request or issue number that is not one is refused** before the forge is asked: a tool called with a
|
||||
misnamed argument sent `pulls/NaN`.
|
||||
|
||||
@@ -154,15 +156,19 @@ listed repository:
|
||||
naming the description, and a `merge-check.sh` declaring `# mesh-check-toolchain: go` that vets and runs the unit
|
||||
tests, then the integration tests (`-tags integration`), of every Go module in the tree, and says so and passes on
|
||||
a tree with none. The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges),
|
||||
requiring `mesh/repo-check` and `mesh/merge-gate`, and only then is the repository moved into the organisation.
|
||||
requiring `mesh/check` (ADR 0447 §1, issue 1301), and only then is the repository moved into the organisation.
|
||||
There main's rule is read back and put right once more (the step `rule-after-move`): the forge drops from a merge
|
||||
list a user without access to the repository, and under the setup account `mesh-tools` has none;
|
||||
- one found still under the setup account is finished: the steps it lacks are done, main is never written twice;
|
||||
- one in the organisation is only put right: main's rule narrowed, a missing one of the two statuses added and every
|
||||
status already there kept.
|
||||
- one in the organisation is only put right: main's rule narrowed, `mesh/check` required in place of
|
||||
`mesh/merge-gate` and `mesh/repo-check`, and every other status already there kept. A main that requires
|
||||
`mesh/repo-check` without `mesh/merge-gate` is judged by its own script alone (hq, ADR 0503 §5, until issue 1296)
|
||||
and keeps it; `mesh/check` is never dropped.
|
||||
|
||||
A step the forge refuses stops that repository, and the others go on. A repository taken out of the value is never
|
||||
asked about: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
|
||||
Every other repository of the organisation whose main requires `mesh/merge-gate` has the two old statuses swapped
|
||||
for `mesh/check`, and only its statuses (issue 1301): the switch reaches every repository the merge gate judged, with
|
||||
no list kept by hand. A step the forge refuses stops that repository, and the others go on. Beyond that swap, a
|
||||
repository taken out of the value is never changed: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
|
||||
`repositories-outcome.json`.
|
||||
|
||||
**How a refusal is seen.** `repositories sync` is a run-once process, declared as the module's last resource, so
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1
|
||||
// before it; novox/hq issue 1301; ADR 0447 §1, ADR 0503 §3: main requires mesh/check alone).
|
||||
//
|
||||
// No tool writes mesh/check: a tool is told no caller, and the console every agent session speaks through (node-tools)
|
||||
// invokes every tool, so a tool that wrote it could be called by any session to merge past the checker (the fault issue
|
||||
// 293 closed for the two statuses before it). Nor does an event: nothing in the delivery plan orders this module before the
|
||||
// forge bridge, and a durable consumer whose filters change keeps its deliver policy, so an event said before this module
|
||||
// consumed it was lost. The checker keeps what it owes the forge in its state `owed` (ADR 0484 §2), which it announces
|
||||
// readable (ADR 0495 §2-§3), and only the checker writes it. This module watches it: the watch hands over the latest value of
|
||||
// every key when it starts, whenever that is, and then every change. Each key whose act is the status mesh/check is
|
||||
// written on its head, once per revision; a revision older than one already written on the key is dropped.
|
||||
//
|
||||
// A read refused (the grant not yet composed) is said once and asked again on a bound until it holds. A forge that refuses
|
||||
// a status is asked again on a bound, until it writes or a newer revision of the key comes.
|
||||
|
||||
import type { CommitStatus } from "./client.js";
|
||||
import { bindUntilHeld, bindWait } from "./bind.js";
|
||||
import { checkName } from "./guard.js";
|
||||
import { CHECK_STATUS } from "./pulls.js";
|
||||
|
||||
/** The checker's state this module reads, and the act of its keys this module writes. */
|
||||
export const CHECKER_OWED = "mesh-delivery-checker.owed";
|
||||
export const CHECK_ACT = `status=${CHECK_STATUS}`;
|
||||
|
||||
const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
|
||||
/** What writing the check needs of the forge. */
|
||||
export interface CheckForge {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
}
|
||||
|
||||
/** One change of the checker's owed, as the SDK's state watch hands it. */
|
||||
export interface OwedChange {
|
||||
key: string;
|
||||
op: "put" | "delete";
|
||||
value?: unknown;
|
||||
revision: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
/** A key part as mesh-sdk go/delivery EscapeName writes it, read back: `=` and two lowercase hex digits per byte. */
|
||||
function unescapeName(part: string): string {
|
||||
if (!/^([-/_a-zA-Z0-9]|=[0-9a-f]{2})+$/.test(part)) throw new Error(`${JSON.stringify(part)} is not an escaped name`);
|
||||
return Buffer.from(part.replace(/=([0-9a-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), "latin1").toString("utf8");
|
||||
}
|
||||
|
||||
/** The pull request and head of the checker's owed key (mesh-sdk go/delivery CheckerOwedKey):
|
||||
* `<owner>/<name>.<number>.<head>.status=mesh/check`, owner and name escaped. */
|
||||
export function owedKeyOf(key: string): { owner: string; repo: string; number: number; sha: string } {
|
||||
const parts = String(key ?? "").split(".");
|
||||
if (parts.length !== 4) throw new Error(`${JSON.stringify(key)} is not an owed key, <repository>.<number>.<head>.<act>`);
|
||||
const [repository, number, sha, act] = parts;
|
||||
if (act !== CHECK_ACT) throw new Error(`${JSON.stringify(key)} owes ${JSON.stringify(act)}, not ${CHECK_ACT}`);
|
||||
const m = /^([^/]+)\/([^/]+)$/.exec(repository);
|
||||
if (!m) throw new Error(`${JSON.stringify(key)} names no <owner>/<name>`);
|
||||
if (!/^(0|[1-9][0-9]*)$/.test(number)) throw new Error(`${JSON.stringify(key)} names no pull request number`);
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(key)} names no head's whole sha`);
|
||||
return { owner: checkName("owner", unescapeName(m[1])), repo: checkName("name", unescapeName(m[2])), number: Number(number), sha };
|
||||
}
|
||||
|
||||
/** The status an owed change asks for, checked as mesh-sdk go/delivery DecodeCheckerOwed checks it; or why it is not one. */
|
||||
export function checkStatusOf(change: OwedChange): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } {
|
||||
if (change.op !== "put") throw new Error(`a ${change.op} owes nothing`);
|
||||
const { owner, repo, sha } = owedKeyOf(change.key);
|
||||
const revision = change.revision;
|
||||
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) {
|
||||
throw new Error(`${JSON.stringify(revision)} is not a revision`);
|
||||
}
|
||||
const v = change.value;
|
||||
if (!v || typeof v !== "object") throw new Error("the value is no owed act");
|
||||
const o = v as Record<string, unknown>;
|
||||
if (o.v !== 1) throw new Error(`the owed act's version ${JSON.stringify(o.v)} is not one this module reads`);
|
||||
if (o.act !== "status" || o.context !== CHECK_STATUS) {
|
||||
throw new Error(`the owed act is ${JSON.stringify(o.act)} ${JSON.stringify(o.context)}, not the status ${CHECK_STATUS}`);
|
||||
}
|
||||
const state = String(o.state ?? "");
|
||||
if (!states.has(state)) throw new Error(`${JSON.stringify(o.state)} is not a status the forge keeps`);
|
||||
let description = String(o.text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (description.length > 140) description = description.slice(0, 139) + "…";
|
||||
const target = typeof o.target === "string" && /^https?:\/\//.test(o.target) ? o.target : undefined;
|
||||
const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description,
|
||||
...(target ? { target_url: target } : {}) };
|
||||
return { owner, repo, sha, revision, status };
|
||||
}
|
||||
|
||||
/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */
|
||||
export class CheckStatuses {
|
||||
/** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */
|
||||
private written = new Map<string, number>();
|
||||
private seen = new Map<string, number>();
|
||||
/** The turn each key's writes wait on, and the asks again still running. */
|
||||
private turns = new Map<string, Promise<unknown>>();
|
||||
private again = new Set<Promise<void>>();
|
||||
private forge: CheckForge;
|
||||
private say: (line: string) => void;
|
||||
private sleep: (ms: number) => Promise<void>;
|
||||
|
||||
constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l),
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
||||
this.forge = forge;
|
||||
this.say = say;
|
||||
this.sleep = sleep;
|
||||
}
|
||||
|
||||
/** Write the change's status: "written", or "dropped" for one that owes no mesh/check or is older than one written. A
|
||||
* forge that refuses throws. */
|
||||
async apply(change: OwedChange): Promise<"written" | "dropped"> {
|
||||
let asked;
|
||||
try {
|
||||
asked = checkStatusOf(change);
|
||||
} catch (err) {
|
||||
this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`);
|
||||
return "dropped";
|
||||
}
|
||||
const key = change.key;
|
||||
const seen = this.seen.get(key);
|
||||
if (seen === undefined || asked.revision > seen) this.remember(this.seen, key, asked.revision);
|
||||
// One key's writes take turns, so two at once are weighed one after the other.
|
||||
const before = this.turns.get(key) ?? Promise.resolve();
|
||||
const turn = before.then(async (): Promise<"written" | "dropped"> => {
|
||||
const newest = this.written.get(key);
|
||||
if (newest !== undefined && asked.revision < newest) {
|
||||
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
|
||||
return "dropped";
|
||||
}
|
||||
await this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status);
|
||||
this.remember(this.written, key, asked.revision);
|
||||
return "written";
|
||||
});
|
||||
const settled = turn.catch(() => {});
|
||||
this.turns.set(key, settled);
|
||||
void settled.then(() => { if (this.turns.get(key) === settled) this.turns.delete(key); });
|
||||
return turn;
|
||||
}
|
||||
|
||||
/** What the watch hands over: written, or when the forge refuses, asked again on a bound in the background until it is
|
||||
* written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses the change. */
|
||||
async take(change: OwedChange): Promise<void> {
|
||||
try {
|
||||
await this.apply(change);
|
||||
} catch (err) {
|
||||
const run = this.askAgain(change, err);
|
||||
this.again.add(run);
|
||||
void run.finally(() => this.again.delete(run));
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves when no ask again is running. */
|
||||
async settled(): Promise<void> {
|
||||
while (this.again.size) await Promise.all([...this.again]);
|
||||
}
|
||||
|
||||
private async askAgain(change: OwedChange, first: unknown): Promise<void> {
|
||||
let err = first;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const wait = bindWait(attempt);
|
||||
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`);
|
||||
await this.sleep(wait);
|
||||
const newest = this.seen.get(change.key);
|
||||
if (newest !== undefined && newest > change.revision) return;
|
||||
try {
|
||||
await this.apply(change);
|
||||
return;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private remember(map: Map<string, number>, key: string, revision: number): void {
|
||||
map.delete(key);
|
||||
map.set(key, revision);
|
||||
if (map.size > 4000) map.delete(map.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the checker's owed with `watch` (the SDK's `state(CHECKER_OWED).watch`), handing each change to `checks`. A watch
|
||||
* refused — the read not granted yet — is said once and asked again on a bound until it holds (bind.ts). Never rejects.
|
||||
*/
|
||||
export async function watchOwed(watch: (handler: (change: OwedChange) => Promise<void>) => Promise<void>, checks: CheckStatuses,
|
||||
sleep?: (ms: number) => Promise<void>, say: (line: string) => void = (l) => console.error(l)): Promise<void> {
|
||||
let refusals = 0;
|
||||
await bindUntilHeld(CHECKER_OWED, () => watch((change) => checks.take(change)), sleep, (line) => {
|
||||
if (/could not be bound/.test(line) && refusals++ > 0) return;
|
||||
say(line);
|
||||
});
|
||||
}
|
||||
+27
-12
@@ -533,7 +533,7 @@ export class GiteaClient {
|
||||
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
|
||||
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
|
||||
* see, since a directory left out would be taken for one without a module. Reads only. */
|
||||
async commitTrees(owner: string, repo: string, sha: string): Promise<{ root: string; trees: Record<string, string> }> {
|
||||
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> {
|
||||
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
|
||||
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
|
||||
const entries: any[] = [];
|
||||
@@ -560,7 +560,22 @@ export class GiteaClient {
|
||||
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
|
||||
trees[dir] = tree;
|
||||
}
|
||||
return { root, trees };
|
||||
if (paths === undefined) return { root, trees };
|
||||
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
|
||||
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
|
||||
// naming both, and nothing is answered in part.
|
||||
const objectOf = new Map<string, string>();
|
||||
for (const e of entries) if (typeof e?.path === "string" && (e.type === "tree" || e.type === "blob")) objectOf.set(e.path, String(e.sha));
|
||||
const named: Record<string, Record<string, string>> = {};
|
||||
for (const [dir, list] of Object.entries(paths)) {
|
||||
named[dir] = {};
|
||||
for (const p of list) {
|
||||
const id = objectOf.get(dir === "" ? p : `${dir}/${p}`);
|
||||
if (!id) throw new Error(`the path ${p} of ${dir === "" ? "the repository root" : dir} is not in ${owner}/${repo} at ${sha}`);
|
||||
named[dir][p] = id;
|
||||
}
|
||||
}
|
||||
return { root, trees, paths: named };
|
||||
}
|
||||
|
||||
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
|
||||
@@ -684,9 +699,9 @@ export class GiteaClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
|
||||
* branch is edited — its required statuses replaced by these, everything else it says kept unless
|
||||
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
|
||||
/** Protect a branch (novox/hq ADR 0237): the rule named for the branch is edited — its required statuses taken
|
||||
* off (ADR 0534 §5), everything else it says kept unless asked — or created, refusing direct pushes unless asked
|
||||
* otherwise. Answers the rule as it is now. */
|
||||
async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> {
|
||||
const had = await this.branchProtection(owner, repo, branch);
|
||||
const body = protectionBody(want, !had, branch);
|
||||
@@ -917,9 +932,9 @@ export class GiteaAdmin {
|
||||
return Array.isArray(res.body) ? res.body : [];
|
||||
}
|
||||
|
||||
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, and
|
||||
* only the tools' account merges. The rule there is edited, its statuses left as they are; a repository with no
|
||||
* rule for main gets one that requires none. Answers the repositories it touched. With the administrator's login:
|
||||
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, only
|
||||
* the tools' account merges, and no status required (ADR 0534 §1). The rule there is edited; a repository with no
|
||||
* rule for main gets one. Answers the repositories it touched. With the administrator's login:
|
||||
* the tools' account is no repository's administrator, so no tool can do this (ADR 0365 §2). */
|
||||
async ensureMainMerges(org: string): Promise<string[]> {
|
||||
const repos: string[] = [];
|
||||
@@ -939,11 +954,11 @@ export class GiteaAdmin {
|
||||
if (patch.status !== 200) GiteaAdmin.fail(rule, patch);
|
||||
continue;
|
||||
}
|
||||
// A rule named exactly main is matched before any glob rule, so it would hide the statuses, approvals and
|
||||
// administrator limit a glob rule covering main asks: they are copied into it (issue 948, review round 1).
|
||||
// A rule named exactly main is matched before any glob rule, so it would hide the approvals and administrator
|
||||
// limit a glob rule covering main asks: they are copied into it (issue 948, review round 1). Its statuses are
|
||||
// not: main requires none (ADR 0534 §1).
|
||||
const covering = rules.filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH));
|
||||
const checks = [...new Set(covering.filter((r) => r.enable_status_check).flatMap((r) => (r.status_check_contexts ?? []).filter(Boolean)))];
|
||||
const copied: Record<string, unknown> = { enable_status_check: checks.length > 0, status_check_contexts: checks };
|
||||
const copied: Record<string, unknown> = {};
|
||||
const approvals = Math.max(0, ...covering.map((r) => Number(r.required_approvals ?? 0)));
|
||||
if (approvals > 0) copied.required_approvals = approvals;
|
||||
if (covering.some((r) => r.block_admin_merge_override === true)) copied.block_admin_merge_override = true;
|
||||
|
||||
@@ -179,7 +179,7 @@ func newTeamRig(t *testing.T) *teamRig {
|
||||
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) {}})
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ func TestABuildModulesEntryWaitsForItsOwnBuilderKey(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)
|
||||
if err != nil || got != 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.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}
|
||||
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{}
|
||||
if err := r.svc.WatchSource(context.Background(), module, w.sources[module]); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -154,7 +154,7 @@ func TestTheManifestReadsTheBuildModulesKeys(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
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) {
|
||||
t.Errorf("gitea's reads %v lack %q", m.Reads, state)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// A unified diff applied by the tool itself (novox/hq issue 1604). The forge's diffpatch answers 500 with an empty
|
||||
// message when an unchanged context line holds a non-ASCII character, so a patch the forge did not take is applied
|
||||
// here: each touched file is read at the commit the commit goes on, its hunks are applied, and the resulting
|
||||
// files are committed through the same call a files commit uses. The forge's files call carries no file mode, so
|
||||
// a patch that creates an executable file, changes a mode or touches a symlink is not applied here: the tool says so.
|
||||
|
||||
// fileSource reads a file at the commit the patch is applied to; ok is false when there is no such file.
|
||||
type fileSource func(path string) (content []byte, mode string, ok bool, err error)
|
||||
|
||||
// patchFile is one file section of a patch.
|
||||
type patchFile struct {
|
||||
oldPath, newPath string // "" when /dev/null
|
||||
create, remove bool
|
||||
renamed bool
|
||||
oldSeen bool
|
||||
hunks []hunk
|
||||
modeBlock string // why the files call cannot carry this section, "" when it can
|
||||
}
|
||||
|
||||
type hunk struct {
|
||||
oldStart int
|
||||
old, new []string
|
||||
oldNoNL, newNoNL bool
|
||||
leading int // unchanged lines before the first change
|
||||
trailing int // unchanged lines after the last change
|
||||
}
|
||||
|
||||
var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
|
||||
|
||||
// gitPath reads a path of a diff header: git quotes one holding unusual bytes in C style.
|
||||
func gitPath(s string) string {
|
||||
s = strings.TrimRight(s, "\t ")
|
||||
if strings.HasPrefix(s, `"`) {
|
||||
if u, err := strconv.Unquote(s); err == nil {
|
||||
return u
|
||||
}
|
||||
} else if i := strings.IndexByte(s, '\t'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func stripPrefix(p string) string {
|
||||
if p == "/dev/null" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexByte(p, '/'); i >= 0 && (p[:i] == "a" || p[:i] == "b") {
|
||||
return p[i+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func nonRegular(mode string) bool { return mode != "" && mode != "100644" }
|
||||
|
||||
// parsePatch reads a unified diff into its file sections.
|
||||
func parsePatch(text string) ([]*patchFile, error) {
|
||||
lines := strings.Split(text, "\n")
|
||||
if n := len(lines); n > 0 && lines[n-1] == "" {
|
||||
lines = lines[:n-1]
|
||||
}
|
||||
var files []*patchFile
|
||||
var cur *patchFile
|
||||
block := func(f *patchFile, what string) {
|
||||
if f.modeBlock == "" {
|
||||
f.modeBlock = what
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(lines); i++ {
|
||||
l := strings.TrimSuffix(lines[i], "\r")
|
||||
switch {
|
||||
case strings.HasPrefix(l, "diff --git "):
|
||||
cur = &patchFile{}
|
||||
files = append(files, cur)
|
||||
rest := strings.TrimPrefix(l, "diff --git ")
|
||||
if k := strings.Index(rest, " b/"); k > 0 && !strings.HasPrefix(rest, `"`) {
|
||||
cur.oldPath, cur.newPath = stripPrefix(rest[:k]), stripPrefix(rest[k+1:])
|
||||
}
|
||||
case cur != nil && strings.HasPrefix(l, "new file mode "):
|
||||
cur.create = true
|
||||
if m := strings.TrimPrefix(l, "new file mode "); nonRegular(m) {
|
||||
block(cur, "creates a file of mode "+m)
|
||||
}
|
||||
case cur != nil && strings.HasPrefix(l, "deleted file mode "):
|
||||
cur.remove = true
|
||||
case cur != nil && strings.HasPrefix(l, "old mode "):
|
||||
block(cur, "changes the file mode ("+l+")")
|
||||
case cur != nil && strings.HasPrefix(l, "new mode "):
|
||||
block(cur, "changes the file mode ("+l+")")
|
||||
case cur != nil && strings.HasPrefix(l, "index "):
|
||||
if f := strings.Fields(l); len(f) == 3 && nonRegular(f[2]) {
|
||||
block(cur, "touches a file of mode "+f[2])
|
||||
}
|
||||
case cur != nil && strings.HasPrefix(l, "rename from "):
|
||||
cur.oldPath, cur.renamed = gitPath(strings.TrimPrefix(l, "rename from ")), true
|
||||
case cur != nil && strings.HasPrefix(l, "rename to "):
|
||||
cur.newPath, cur.renamed = gitPath(strings.TrimPrefix(l, "rename to ")), true
|
||||
case strings.HasPrefix(l, "copy from "), strings.HasPrefix(l, "copy to "):
|
||||
return nil, fmt.Errorf("a copy in a patch (%s) is not applied by the tool", l)
|
||||
case strings.HasPrefix(l, "GIT binary patch"), strings.HasPrefix(l, "Binary files "):
|
||||
return nil, fmt.Errorf("a binary patch is not applied by the tool")
|
||||
case strings.HasPrefix(l, "--- "):
|
||||
if cur == nil || cur.oldSeen {
|
||||
cur = &patchFile{}
|
||||
files = append(files, cur)
|
||||
}
|
||||
p := stripPrefix(gitPath(strings.TrimPrefix(l, "--- ")))
|
||||
cur.oldPath = p
|
||||
if p == "" {
|
||||
cur.create = true
|
||||
}
|
||||
cur.oldSeen = true
|
||||
if i+1 >= len(lines) || !strings.HasPrefix(lines[i+1], "+++ ") {
|
||||
return nil, fmt.Errorf("the line %d, %q, is not followed by a +++ line", i+1, l)
|
||||
}
|
||||
i++
|
||||
q := stripPrefix(gitPath(strings.TrimPrefix(strings.TrimSuffix(lines[i], "\r"), "+++ ")))
|
||||
cur.newPath = q
|
||||
if q == "" {
|
||||
cur.remove = true
|
||||
}
|
||||
case cur != nil && hunkHeader.MatchString(l):
|
||||
m := hunkHeader.FindStringSubmatch(l)
|
||||
atoi := func(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
h := hunk{oldStart: atoi(m[1], 0)}
|
||||
oldN, newN := atoi(m[2], 1), atoi(m[4], 1)
|
||||
startLine := i + 1
|
||||
noNL := func() { // "\ No newline at end of file" is about the line before it
|
||||
prev := lines[i-1]
|
||||
switch {
|
||||
case prev != "" && prev[0] == '-':
|
||||
h.oldNoNL = true
|
||||
case prev != "" && prev[0] == '+':
|
||||
h.newNoNL = true
|
||||
default:
|
||||
h.oldNoNL, h.newNoNL = true, true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
for oldN > 0 || newN > 0 {
|
||||
i++
|
||||
if i >= len(lines) {
|
||||
return nil, fmt.Errorf("the hunk of %s at patch line %d ends before its %d old and %d new lines are all there", cur.name(), startLine, oldN, newN)
|
||||
}
|
||||
b := strings.TrimSuffix(lines[i], "\r")
|
||||
if b == "" {
|
||||
b = " " // a blank context line some tools strip to nothing, or a bare CR
|
||||
}
|
||||
switch b[0] {
|
||||
case '\\':
|
||||
noNL()
|
||||
case ' ':
|
||||
text := lines[i]
|
||||
if strings.HasPrefix(text, " ") {
|
||||
text = text[1:] // a bare "" or "\r" is the whole blank line, its leading space stripped
|
||||
}
|
||||
h.old, h.new = append(h.old, text), append(h.new, text)
|
||||
oldN--
|
||||
newN--
|
||||
if changed {
|
||||
h.trailing++
|
||||
} else {
|
||||
h.leading++
|
||||
}
|
||||
case '-':
|
||||
h.old = append(h.old, lines[i][1:])
|
||||
oldN--
|
||||
changed, h.trailing = true, 0
|
||||
case '+':
|
||||
h.new = append(h.new, lines[i][1:])
|
||||
newN--
|
||||
changed, h.trailing = true, 0
|
||||
default:
|
||||
return nil, fmt.Errorf("the hunk of %s at patch line %d has a line %d that is neither context, - nor +: %q", cur.name(), startLine, i+1, lines[i])
|
||||
}
|
||||
}
|
||||
for i+1 < len(lines) && strings.HasPrefix(lines[i+1], `\`) {
|
||||
i++
|
||||
noNL()
|
||||
}
|
||||
cur.hunks = append(cur.hunks, h)
|
||||
}
|
||||
}
|
||||
for _, f := range files {
|
||||
if f.newPath == "" && f.oldPath == "" {
|
||||
return nil, fmt.Errorf("a file section of the patch names no file")
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("the patch holds no file section")
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (f *patchFile) name() string {
|
||||
if f.newPath != "" {
|
||||
return f.newPath
|
||||
}
|
||||
return f.oldPath
|
||||
}
|
||||
|
||||
// textFile is a file's content as lines, and whether its last line ends with a newline.
|
||||
type textFile struct {
|
||||
lines []string
|
||||
endNL bool
|
||||
}
|
||||
|
||||
func splitText(b []byte) textFile {
|
||||
if len(b) == 0 {
|
||||
return textFile{endNL: true}
|
||||
}
|
||||
s := string(b)
|
||||
endNL := strings.HasSuffix(s, "\n")
|
||||
s = strings.TrimSuffix(s, "\n")
|
||||
return textFile{lines: strings.Split(s, "\n"), endNL: endNL}
|
||||
}
|
||||
|
||||
func (t textFile) bytes() []byte {
|
||||
if len(t.lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
s := strings.Join(t.lines, "\n")
|
||||
if t.endNL {
|
||||
s += "\n"
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
// apply puts one hunk on the file, at where the hunk says or, as git apply does, at the nearest place its old
|
||||
// lines are found exactly; delta is how far earlier hunks of this file moved lines.
|
||||
func (t textFile) apply(h hunk, delta int) (textFile, int, error) {
|
||||
want := h.oldStart - 1 + delta
|
||||
if len(h.old) == 0 && h.oldStart > 0 {
|
||||
want = h.oldStart + delta // a pure insertion is "after" the line it names
|
||||
}
|
||||
if want < 0 {
|
||||
want = 0
|
||||
}
|
||||
matches := func(at int) bool {
|
||||
if at < 0 || at+len(h.old) > len(t.lines) {
|
||||
return false
|
||||
}
|
||||
for k, l := range h.old {
|
||||
if t.lines[at+k] != l {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// the old side's last line lacks a newline only when the file's does
|
||||
if at+len(h.old) == len(t.lines) && len(h.old) > 0 && h.oldNoNL == t.endNL {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// As git apply does: a hunk that starts at line 1 (or 0) begins the file, and a hunk with context but none
|
||||
// after its last change ends it; a hunk whose old side lacks the final newline ends it too.
|
||||
atStart := h.oldStart <= 1
|
||||
atEnd := (h.trailing == 0 && h.leading > 0) || h.oldNoNL
|
||||
at := -1
|
||||
switch {
|
||||
case atStart && atEnd:
|
||||
if len(t.lines) == len(h.old) && matches(0) {
|
||||
at = 0
|
||||
}
|
||||
case atStart:
|
||||
if matches(0) {
|
||||
at = 0
|
||||
}
|
||||
case atEnd:
|
||||
if e := len(t.lines) - len(h.old); matches(e) {
|
||||
at = e
|
||||
}
|
||||
}
|
||||
for d := 0; !atStart && !atEnd && d <= len(t.lines)+1 && at < 0; d++ {
|
||||
switch {
|
||||
case matches(want - d):
|
||||
at = want - d
|
||||
case matches(want + d):
|
||||
at = want + d
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
return t, delta, fmt.Errorf("the hunk at line %d does not apply: its context and removed lines are not in the file (first of them: %q)", h.oldStart, firstOf(h.old))
|
||||
}
|
||||
out := append(append(append([]string{}, t.lines[:at]...), h.new...), t.lines[at+len(h.old):]...)
|
||||
endNL := t.endNL
|
||||
if at+len(h.old) == len(t.lines) && (len(h.new) > 0 || len(h.old) > 0) {
|
||||
endNL = !h.newNoNL
|
||||
}
|
||||
return textFile{lines: out, endNL: endNL}, delta + len(h.new) - len(h.old), nil
|
||||
}
|
||||
|
||||
func firstOf(l []string) string {
|
||||
if len(l) == 0 {
|
||||
return ""
|
||||
}
|
||||
return l[0]
|
||||
}
|
||||
|
||||
// patchAsFiles applies the patch to the files read through read, and says the files to commit.
|
||||
func patchAsFiles(text string, read fileSource) ([]FileChange, error) {
|
||||
sections, err := parsePatch(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, s := range sections {
|
||||
if s.modeBlock != "" {
|
||||
return nil, fmt.Errorf("%s %s, and the forge's files call carries no file mode", s.name(), s.modeBlock)
|
||||
}
|
||||
}
|
||||
work := map[string][]byte{} // the files as they are being changed; nil means absent
|
||||
orig := map[string]bool{} // which paths existed
|
||||
load := func(p string) error {
|
||||
if _, seen := work[p]; seen {
|
||||
return nil
|
||||
}
|
||||
b, mode, ok, err := read(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok && mode != "" && mode != "100644" {
|
||||
return fmt.Errorf("%s is a file of mode %s, and the forge's files call carries no file mode, so committing it that way would make it 100644", p, mode)
|
||||
}
|
||||
orig[p] = ok
|
||||
if ok {
|
||||
if b == nil {
|
||||
b = []byte{}
|
||||
}
|
||||
work[p] = b
|
||||
} else {
|
||||
work[p] = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, s := range sections {
|
||||
from, to := s.oldPath, s.newPath
|
||||
if s.create {
|
||||
from = ""
|
||||
}
|
||||
var t textFile
|
||||
if from != "" {
|
||||
if err := load(from); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if work[from] == nil {
|
||||
return nil, fmt.Errorf("%s: the file is not there at the commit the patch goes on, so the patch does not apply", from)
|
||||
}
|
||||
if !utf8.Valid(work[from]) && len(s.hunks) > 0 {
|
||||
return nil, fmt.Errorf("%s is not UTF-8 text: the tool applies text patches only", from)
|
||||
}
|
||||
t = splitText(work[from])
|
||||
} else {
|
||||
if err := load(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if work[to] != nil {
|
||||
return nil, fmt.Errorf("%s: the patch creates it, but it is already there at the commit the patch goes on", to)
|
||||
}
|
||||
t = textFile{endNL: true}
|
||||
}
|
||||
delta := 0
|
||||
for _, h := range s.hunks {
|
||||
if t, delta, err = t.apply(h, delta); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", s.name(), err)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case s.remove:
|
||||
if len(t.lines) != 0 {
|
||||
return nil, fmt.Errorf("%s: the patch deletes the file, but lines are left after its hunks", from)
|
||||
}
|
||||
work[from] = nil
|
||||
default:
|
||||
if s.renamed && from != to {
|
||||
work[from] = nil
|
||||
if err := load(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
body := t.bytes()
|
||||
if body == nil {
|
||||
body = []byte{}
|
||||
}
|
||||
work[to] = body
|
||||
}
|
||||
}
|
||||
var paths []string
|
||||
for p := range work {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
var out []FileChange
|
||||
for _, p := range paths {
|
||||
switch {
|
||||
case work[p] == nil && !orig[p]:
|
||||
case work[p] == nil:
|
||||
out = append(out, FileChange{Path: p, Delete: true})
|
||||
default:
|
||||
fc := FileChange{Path: p, size: len(work[p])}
|
||||
if utf8.Valid(work[p]) {
|
||||
s := string(work[p])
|
||||
fc.Content = &s
|
||||
} else {
|
||||
s := base64Std(work[p])
|
||||
fc.ContentBase64 = &s
|
||||
}
|
||||
out = append(out, fc)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("the patch changes no file")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func base64Std(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 1604: the forge's diffpatch answers 500 when an unchanged context line holds a non-ASCII
|
||||
// character; the tool then applies the patch itself.
|
||||
|
||||
func sectionFake(t *testing.T) *fakeForge {
|
||||
f := newFake(t)
|
||||
f.trees["c0"]["doc.md"] = "# Title\n\nSee § 4 for the rule.\nold line\nend § here\n"
|
||||
f.trees["c0"]["run.sh"] = "#!/bin/sh\necho a\n"
|
||||
f.patchStatus = 500
|
||||
return f
|
||||
}
|
||||
|
||||
const sectionPatch = "diff --git a/doc.md b/doc.md\n--- a/doc.md\n+++ b/doc.md\n@@ -2,4 +2,4 @@\n \n See § 4 for the rule.\n-old line\n+new line\n end § here\n"
|
||||
|
||||
func TestAPatchWhoseContextHoldsANonASCIICharacterIsAppliedByTheToolWhenTheForgeAnswers500(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "# Title\n\nSee § 4 for the rule.\nnew line\nend § here\n"
|
||||
if tree := f.trees[got.Commit]; tree["doc.md"] != want || tree["README.md"] != "hello\n" {
|
||||
t.Fatalf("tree %v", tree)
|
||||
}
|
||||
if len(f.patches) != 1 || len(f.writes) != 1 || f.writes[0]["new_branch"] != "fix/1604" || f.writes[0]["branch"] != "main" {
|
||||
t.Fatalf("patches %v writes %v", f.patches, f.writes)
|
||||
}
|
||||
if len(got.Files) != 1 || got.Files[0].Operation != "update" || got.Parent != "c0" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if !strings.Contains(strings.Join(got.Said, " "), "applied it") {
|
||||
t.Errorf("not said: %v", got.Said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchTheForgeTakesIsStillTheForgesToApply(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
f.patchStatus = 0
|
||||
if _, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.writes) != 0 {
|
||||
t.Fatal("the tool applied a patch the forge took")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchThatCreatesDeletesAndRenamesIsApplied(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
patch := "diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,2 @@\n+one §\n+two\n" +
|
||||
"diff --git a/docs/a.md b/docs/a.md\ndeleted file mode 100644\n--- a/docs/a.md\n+++ /dev/null\n@@ -1 +0,0 @@\n-a\n" +
|
||||
"diff --git a/README.md b/README.txt\nsimilarity index 80%\nrename from README.md\nrename to README.txt\n--- a/README.md\n+++ b/README.txt\n@@ -1 +1 @@\n-hello\n+hello §\n"
|
||||
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/r", "patch": patch}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree := f.trees[got.Commit]
|
||||
if tree["new.txt"] != "one §\ntwo\n" || tree["README.txt"] != "hello §\n" {
|
||||
t.Fatalf("tree %v", tree)
|
||||
}
|
||||
if _, has := tree["README.md"]; has {
|
||||
t.Error("the renamed file is still there")
|
||||
}
|
||||
if _, has := tree["docs/a.md"]; has {
|
||||
t.Error("the deleted file is still there")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHunkMovedByEarlierLinesIsFoundAndNoNewlineIsKept(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
f.trees["c0"]["x.txt"] = "pre\npre\nabc §\nd\ne"
|
||||
// the hunk below starts at line 2, though its lines are at 3: the tool finds them one line on
|
||||
patch := "--- a/x.txt\n+++ b/x.txt\n@@ -2,3 +2,3 @@\n abc §\n d\n-e\n\\ No newline at end of file\n+f\n\\ No newline at end of file\n"
|
||||
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/n", "patch": patch}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g := f.trees[got.Commit]["x.txt"]; g != "pre\npre\nabc §\nd\nf" {
|
||||
t.Fatalf("%q", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchThatDoesNotApplyIsRefusedNamingTheFileAndLine(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
patch := strings.Replace(sectionPatch, "-old line", "-other line", 1)
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": patch}))
|
||||
if err == nil || !strings.Contains(err.Error(), "doc.md") || !strings.Contains(err.Error(), "at line 2 does not apply") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if len(f.writes) != 0 {
|
||||
t.Fatal("something was written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchWithAnExecutableFileIsNotAppliedByTheToolAndSaysWhy(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
for _, patch := range []string{
|
||||
"diff --git a/s.sh b/s.sh\nnew file mode 100755\n--- /dev/null\n+++ b/s.sh\n@@ -0,0 +1 @@\n+echo §\n",
|
||||
"diff --git a/run.sh b/run.sh\nold mode 100644\nnew mode 100755\n",
|
||||
"diff --git a/run.sh b/run.sh\nindex 1111111..2222222 100755\n--- a/run.sh\n+++ b/run.sh\n@@ -1,2 +1,2 @@\n #!/bin/sh\n-echo a\n+echo b\n",
|
||||
} {
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/x", "patch": patch}))
|
||||
if err == nil || !strings.Contains(err.Error(), "the forge did not take the patch") || !strings.Contains(err.Error(), "carries no file mode") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
if len(f.writes) != 0 {
|
||||
t.Fatal("something was written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClientErrorOfTheForgeIsNotRetriedByTheTool(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
f.patchStatus = 422
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch}))
|
||||
if err == nil || len(f.writes) != 0 {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADefaultBranchIsStillRefusedForAPatch(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "main", "patch": sectionPatch}))
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0266") || len(f.patches)+len(f.writes) != 0 {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1.
|
||||
|
||||
func appliedTo(t *testing.T, content, patch string) (string, error) {
|
||||
t.Helper()
|
||||
f := sectionFake(t)
|
||||
f.trees["c0"]["y.txt"] = content
|
||||
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/y", "patch": patch}))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f.trees[got.Commit]["y.txt"], nil
|
||||
}
|
||||
|
||||
func TestAHunkAtLineOneMustMatchAtTheStartOfTheFile(t *testing.T) {
|
||||
patch := "--- a/y.txt\n+++ b/y.txt\n@@ -1,3 +1,3 @@\n-a\n+A\n b\n c\n"
|
||||
if got, err := appliedTo(t, "X\nb\nc\nzzz\na\nb\nc\n", patch); err == nil {
|
||||
t.Fatalf("applied in the middle: %q", got)
|
||||
}
|
||||
if got, err := appliedTo(t, "a\nb\nc\nzzz\n", patch); err != nil || got != "A\nb\nc\nzzz\n" {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHunkWithNoTrailingContextMustMatchAtTheEndOfTheFile(t *testing.T) {
|
||||
patch := "--- a/y.txt\n+++ b/y.txt\n@@ -1,3 +1,4 @@\n a\n b\n c\n+NEW\n"
|
||||
if got, err := appliedTo(t, "a\nb\nc\nd\ne\n", patch); err == nil {
|
||||
t.Fatalf("added in the middle: %q", got)
|
||||
}
|
||||
patch = "--- a/y.txt\n+++ b/y.txt\n@@ -3,3 +3,4 @@\n a\n b\n c\n+NEW\n"
|
||||
if got, err := appliedTo(t, "x\ny\na\nb\nc\n", patch); err != nil || got != "x\ny\na\nb\nc\nNEW\n" {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExistingExecutableFileIsNotSilentlyMadeNonExecutable(t *testing.T) {
|
||||
for _, patch := range []string{
|
||||
"diff --git a/run.sh b/run.sh2\nsimilarity index 100%\nrename from run.sh\nrename to run.sh2\n",
|
||||
"--- a/run.sh\n+++ b/run.sh\n@@ -1,2 +1,2 @@\n #!/bin/sh\n-echo a\n+echo b\n",
|
||||
} {
|
||||
f := sectionFake(t)
|
||||
f.modes = map[string]string{"run.sh": "100755"}
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/m", "patch": patch}))
|
||||
if err == nil || !strings.Contains(err.Error(), "100755") || len(f.writes) != 0 {
|
||||
t.Errorf("%v (%d writes)", err, len(f.writes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchWithCRLFLinesAndABareCRContextLineDoesNotPanic(t *testing.T) {
|
||||
patch := "--- a/y.txt\r\n+++ b/y.txt\r\n@@ -1,3 +1,3 @@\r\n a\r\n\r\n-c\r\n+C\r\n"
|
||||
got, err := appliedTo(t, "a\r\n\r\nc\r\n", patch)
|
||||
if err != nil || got != "a\r\n\r\nC\r\n" {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -326,14 +326,49 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
|
||||
var made struct {
|
||||
Commit madeCommit `json:"commit"`
|
||||
}
|
||||
// A patch goes to the forge's diffpatch first. When the forge answers a server error (it does so, with an
|
||||
// empty message, when an unchanged context line holds a non-ASCII character: novox/hq issue 1604) the tool
|
||||
// applies the patch itself and commits the resulting files the way a files commit does.
|
||||
files := a.Files
|
||||
forgeApplied := false
|
||||
if a.Patch != "" {
|
||||
on["content"] = a.Patch
|
||||
if err := f.SendJSON(ctx, http.MethodPost, repo+"/diffpatch", on, &made); err != nil {
|
||||
err := f.SendJSON(ctx, http.MethodPost, repo+"/diffpatch", on, &made)
|
||||
var status *forgehttp.StatusError
|
||||
switch {
|
||||
case err == nil:
|
||||
forgeApplied = true
|
||||
case errors.As(err, &status) && status.Status >= 500:
|
||||
delete(on, "content")
|
||||
read := func(p string) ([]byte, string, bool, error) {
|
||||
b, ok, err := f.contentAt(ctx, repo, p, ref)
|
||||
if err != nil || !ok {
|
||||
return nil, "", false, err
|
||||
}
|
||||
mode, err := f.modeAt(ctx, repo, ref, p)
|
||||
return b, mode, true, err
|
||||
}
|
||||
var perr error
|
||||
if files, perr = patchAsFiles(a.Patch, read); perr != nil {
|
||||
return nil, fmt.Errorf("the forge did not take the patch: %w; the tool could not apply it itself: %v", err, perr)
|
||||
}
|
||||
total := 0
|
||||
for _, fc := range files {
|
||||
if fc.size > maxFileBytes {
|
||||
return nil, refuse("%s would be %s, more than the %s one file of a commit may be. Nothing was sent", fc.Path, mib(fc.size), mib(maxFileBytes))
|
||||
}
|
||||
total += fc.size
|
||||
}
|
||||
if total > maxCommitBytes {
|
||||
return nil, refuse("the patch's files are %s together, more than the %s one commit carries. Nothing was sent", mib(total), mib(maxCommitBytes))
|
||||
}
|
||||
out.Said = append(out.Said, fmt.Sprintf("the forge answered %d to the patch, so the tool applied it to the files at %s and committed them", status.Status, short(ref)))
|
||||
default:
|
||||
return nil, fmt.Errorf("the forge did not take the patch: %w", err)
|
||||
}
|
||||
} else {
|
||||
files := a.Files
|
||||
for _, fc := range a.Files {
|
||||
}
|
||||
if !forgeApplied {
|
||||
for _, fc := range files {
|
||||
if fc.CopyFrom != nil {
|
||||
// Read, and judged against the limits, before anything is sent.
|
||||
var err error
|
||||
@@ -391,7 +426,7 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
|
||||
moved = "base " + out.Base
|
||||
}
|
||||
how := "each file it changes was checked against " + short(read) + " by its blob, so none of them changed in between"
|
||||
if a.Patch != "" {
|
||||
if forgeApplied {
|
||||
how = "the forge applied the patch there, and it refuses a patch whose context changed"
|
||||
}
|
||||
out.Said = append(out.Said, fmt.Sprintf("the %s moved from %s to %s between the read and the commit, so the "+
|
||||
@@ -478,3 +513,82 @@ func short(sha string) string {
|
||||
}
|
||||
return sha
|
||||
}
|
||||
|
||||
// contentAt is the content of a file at a ref; ok is false when there is no file there.
|
||||
func (f *Forge) contentAt(ctx context.Context, repo, path, ref string) ([]byte, bool, error) {
|
||||
seg, err := segments("file path", path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
body, err := f.Do(ctx, http.MethodGet, repo+"/contents/"+seg+"?ref="+queryEscape(ref), nil, maxBody)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
var entry struct {
|
||||
Type string `json:"type"`
|
||||
Size int `json:"size"`
|
||||
Content string `json:"content"`
|
||||
Encoding string `json:"encoding"`
|
||||
}
|
||||
if err := jsonInto(body, &entry); err != nil {
|
||||
return nil, false, fmt.Errorf("the forge's answer about %s is not readable: %w", path, err)
|
||||
}
|
||||
if entry.Type != "file" {
|
||||
return nil, false, refuse("%s is a %s, not a file. Nothing was sent", path, entry.Type)
|
||||
}
|
||||
if entry.Encoding == "base64" && (entry.Content != "" || entry.Size == 0) {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(entry.Content, "\n", ""))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("the content of %s is not base64: %w", path, err)
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
raw, err := f.Do(ctx, http.MethodGet, repo+"/raw/"+seg+"?ref="+queryEscape(ref), nil, maxFileBytes+1)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
// modeAt is the git mode of a file at a commit, read through the git trees API: the contents API carries none.
|
||||
func (f *Forge) modeAt(ctx context.Context, repo, ref, path string) (string, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
tree := ref
|
||||
for depth, name := range parts {
|
||||
found := false
|
||||
for page := 1; ; page++ {
|
||||
var got struct {
|
||||
Tree []struct {
|
||||
Path string `json:"path"`
|
||||
Mode string `json:"mode"`
|
||||
Type string `json:"type"`
|
||||
SHA string `json:"sha"`
|
||||
} `json:"tree"`
|
||||
Total int `json:"total_count"`
|
||||
}
|
||||
if err := f.GetJSON(ctx, fmt.Sprintf("%s/git/trees/%s?per_page=1000&page=%d", repo, url.PathEscape(tree), page), &got); err != nil {
|
||||
return "", fmt.Errorf("the mode of %s could not be read: %w", path, err)
|
||||
}
|
||||
for _, e := range got.Tree {
|
||||
if e.Path != name {
|
||||
continue
|
||||
}
|
||||
if depth == len(parts)-1 {
|
||||
return e.Mode, nil
|
||||
}
|
||||
tree, found = e.SHA, true
|
||||
break
|
||||
}
|
||||
if found || len(got.Tree) == 0 || page*1000 >= got.Total {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && depth < len(parts)-1 {
|
||||
return "", fmt.Errorf("the mode of %s could not be read: %s is not a folder at %s", path, name, short(ref))
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("the mode of %s could not be read: not in the tree of %s", path, short(ref))
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ type fakeForge struct {
|
||||
parentsBroken bool // and GET git/commits fails
|
||||
parents map[string]string // commit -> parent, as GET git/commits says it
|
||||
sources map[string]*sourceRepo
|
||||
modes map[string]string // path -> git mode when not 100644
|
||||
patchStatus int // the status diffpatch answers instead of taking the patch (issue 1604)
|
||||
}
|
||||
|
||||
type move struct {
|
||||
@@ -128,7 +130,48 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
reply(map[string]any{"type": "file", "path": p, "sha": blob(content)})
|
||||
reply(map[string]any{"type": "file", "path": p, "sha": blob(content), "size": len(content), "encoding": "base64",
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content))})
|
||||
case r.Method == "GET" && strings.HasPrefix(path, repo+"/git/trees/"):
|
||||
id := strings.TrimPrefix(path, repo+"/git/trees/")
|
||||
commitID, dir := id, ""
|
||||
if strings.HasPrefix(id, "tree:") {
|
||||
parts := strings.SplitN(id, ":", 3)
|
||||
commitID, dir = parts[1], parts[2]
|
||||
}
|
||||
if c, isBranch := f.branches[commitID]; isBranch {
|
||||
commitID = c
|
||||
}
|
||||
var entries []map[string]any
|
||||
seen := map[string]bool{}
|
||||
for p := range f.trees[commitID] {
|
||||
if dir != "" {
|
||||
if !strings.HasPrefix(p, dir+"/") {
|
||||
continue
|
||||
}
|
||||
p = strings.TrimPrefix(p, dir+"/")
|
||||
}
|
||||
name, rest, nested := strings.Cut(p, "/")
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
full := name
|
||||
if dir != "" {
|
||||
full = dir + "/" + name
|
||||
}
|
||||
if nested {
|
||||
_ = rest
|
||||
entries = append(entries, map[string]any{"path": name, "mode": "040000", "type": "tree", "sha": "tree:" + commitID + ":" + full})
|
||||
continue
|
||||
}
|
||||
mode := "100644"
|
||||
if m, ok := f.modes[full]; ok {
|
||||
mode = m
|
||||
}
|
||||
entries = append(entries, map[string]any{"path": name, "mode": mode, "type": "blob", "sha": blob(f.trees[commitID][full])})
|
||||
}
|
||||
reply(map[string]any{"tree": entries, "total_count": len(entries)})
|
||||
case r.Method == "POST" && path == repo+"/contents":
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
@@ -176,6 +219,10 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.patches = append(f.patches, body)
|
||||
if f.patchStatus != 0 {
|
||||
http.Error(w, `{"message":""}`, f.patchStatus)
|
||||
return
|
||||
}
|
||||
f.next++
|
||||
made := fmt.Sprintf("p%d", f.next)
|
||||
from := f.branches[body["branch"].(string)]
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tests of ADR 0534 §1, §2 (the operator, 2026-10-23): the forge requires no status to merge. Main's rule keeps no
|
||||
// push, no push whitelist, the tools' account alone in its merge whitelist and no administrator merging past it, and
|
||||
// requires no status: the program writes it so on every repository it lists, and takes every required status off a
|
||||
// rule covering main in every repository of the organisation it does not list. Nothing else of an unlisted
|
||||
// repository's rule is sent.
|
||||
|
||||
func narrowedRule(contexts ...any) map[string]any {
|
||||
return map[string]any{
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []any{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": len(contexts) > 0, "status_check_contexts": contexts,
|
||||
"block_admin_merge_override": true,
|
||||
}
|
||||
}
|
||||
|
||||
// requiresNone is whether a rule as the fake forge keeps it requires no status.
|
||||
func requiresNone(rule map[string]any) bool {
|
||||
return rule["enable_status_check"] != true && len(strs(rule["status_check_contexts"])) == 0
|
||||
}
|
||||
|
||||
// 0534-1: a listed repository whose main requires statuses has them all taken off, the rest of the rule kept; a
|
||||
// second pass changes nothing.
|
||||
func TestListedRepositoryRequiresNoStatus(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check", "other/x")}, "main")
|
||||
checked := f.add("novox/checked", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"mesh-delivery": {}, "checked": {}})
|
||||
for name, r := range map[string]*fRepo{"mesh-delivery": repo, "checked": checked} {
|
||||
rule := r.Rules[mainRule]
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("%s's main still requires %v", name, rule["status_check_contexts"])
|
||||
}
|
||||
if rule["enable_push"] != false || rule["enable_merge_whitelist"] != true ||
|
||||
strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools || rule["block_admin_merge_override"] != true {
|
||||
t.Errorf("%s's main lost the rest of its rule: %v", name, rule)
|
||||
}
|
||||
if r := result(o, name); r.Did != DidPutRight {
|
||||
t.Errorf("%s outcome %+v, want put right", name, r)
|
||||
}
|
||||
}
|
||||
before := len(f.writes())
|
||||
o = run(t, s, Value{"mesh-delivery": {}, "checked": {}})
|
||||
if w := f.writes(); len(w) != before {
|
||||
t.Errorf("a second pass wrote %v", w[before:])
|
||||
}
|
||||
if result(o, "checked").Did != DidUnchanged {
|
||||
t.Errorf("second outcome %+v, want unchanged", result(o, "checked"))
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-1: a repository the program makes requires no status from its first rule in the organisation on.
|
||||
func TestNewRepositoryRequiresNoStatus(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{"fresh": {Description: "d"}})
|
||||
rule := f.repos["novox/fresh"].Rules[mainRule]
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("a new repository's main requires %v, want none", rule["status_check_contexts"])
|
||||
}
|
||||
if readme := f.repos["novox/fresh"].Files["README.md"]; strings.Contains(readme, "mesh/merge-gate") || strings.Contains(readme, "mesh/repo-check") {
|
||||
t.Errorf("the README names the statuses of before: %q", readme)
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-1: a listed repository whose main requires nothing stays so, and is not written when the rest is right.
|
||||
func TestListedRuleRequiringNothingIsRight(t *testing.T) {
|
||||
f := newFake()
|
||||
none := f.add("novox/none", map[string]map[string]any{mainRule: narrowedRule()}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"none": {}})
|
||||
if !requiresNone(none.Rules[mainRule]) {
|
||||
t.Errorf("none requires %v", none.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
if result(o, "none").Did != DidUnchanged {
|
||||
t.Errorf("outcome %+v, want unchanged", result(o, "none"))
|
||||
}
|
||||
for _, w := range f.writes() {
|
||||
t.Errorf("a right rule was written: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-1: hq, whose main required mesh/repo-check alone, requires no status either, listed or not.
|
||||
func TestOwnCheckAloneGoesToo(t *testing.T) {
|
||||
f := newFake()
|
||||
hq := f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
lab := f.add("novox/mesh-lab", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"hq": {}})
|
||||
for name, repo := range map[string]*fRepo{"hq": hq, "mesh-lab": lab} {
|
||||
if !requiresNone(repo.Rules[mainRule]) {
|
||||
t.Errorf("%s requires %v, want none", name, repo.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
if r := result(o, name); r.Did != DidPutRight {
|
||||
t.Errorf("%s outcome %+v, want put right", name, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-1: a repository the value does not list whose rule covering main requires any status has them taken off, and
|
||||
// only its statuses: nothing else of its rule is sent. One that requires none is not written to.
|
||||
func TestUnlistedRepositoryRequiringAStatusHasItTakenOff(t *testing.T) {
|
||||
f := newFake()
|
||||
catalog := f.add("novox/mesh-catalog", map[string]map[string]any{mainRule: {
|
||||
"enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/check"},
|
||||
}}, "main")
|
||||
toolsGo := f.add("novox/mesh-tools-go", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
f.add("novox/plain", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
|
||||
f.add("novox/unticked", map[string]map[string]any{mainRule: {"enable_push": false, "enable_status_check": false,
|
||||
"status_check_contexts": []any{"mesh/check"}}}, "main")
|
||||
f.add("novox/norule", nil, "main")
|
||||
f.add("mesh-admin/elsewhere", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{})
|
||||
|
||||
if !requiresNone(catalog.Rules[mainRule]) {
|
||||
t.Errorf("mesh-catalog requires %v, want none", catalog.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
if catalog.Rules[mainRule]["enable_push"] != true {
|
||||
t.Errorf("an unlisted repository's rule was changed beyond its statuses: %v", catalog.Rules[mainRule])
|
||||
}
|
||||
if !requiresNone(toolsGo.Rules[mainRule]) {
|
||||
t.Errorf("mesh-tools-go requires %v, want none", toolsGo.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
want := []string{
|
||||
"PATCH /api/v1/repos/novox/mesh-catalog/branch_protections/main",
|
||||
"PATCH /api/v1/repos/novox/mesh-tools-go/branch_protections/main",
|
||||
}
|
||||
if got := f.writes(); strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("writes:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
for _, n := range []string{"mesh-catalog", "mesh-tools-go"} {
|
||||
if r := result(o, n); r.Did != DidPutRight {
|
||||
t.Errorf("%s outcome %+v, want put right", n, r)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"plain", "unticked", "norule", "elsewhere"} {
|
||||
if r := result(o, n); r.Name != "" {
|
||||
t.Errorf("%s is in the outcome though nothing was done: %+v", n, r)
|
||||
}
|
||||
}
|
||||
before := len(f.writes())
|
||||
run(t, s, Value{})
|
||||
if w := f.writes(); len(w) != before {
|
||||
t.Errorf("a second pass wrote %v", w[before:])
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-1: the organisation's repositories are read page by page, so one past the first page is put right too.
|
||||
func TestUnlistedRepositoriesAreReadPastTheFirstPage(t *testing.T) {
|
||||
f := newFake()
|
||||
f.pageSize = 2
|
||||
for _, n := range []string{"a", "b", "c", "d", "e"} {
|
||||
f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{})
|
||||
for _, n := range []string{"a", "b", "c", "d", "e"} {
|
||||
if rule := f.repos["novox/"+n].Rules[mainRule]; !requiresNone(rule) {
|
||||
t.Errorf("%s requires %v", n, rule["status_check_contexts"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-1: a refused removal is said with the repository and its step, and the pass goes on to the next.
|
||||
func TestRefusedRemovalIsSaid(t *testing.T) {
|
||||
f := newFake()
|
||||
f.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
f.refuse = func(method, path string) (int, string) {
|
||||
if method == "PATCH" && strings.Contains(path, "/novox/a/") {
|
||||
return 403, "no"
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{})
|
||||
if r := result(o, "a"); r.Did != DidRefused || r.Step != StepNoStatus {
|
||||
t.Errorf("a: %+v, want refused at %s", r, StepNoStatus)
|
||||
}
|
||||
if r := result(o, "b"); r.Did != DidPutRight {
|
||||
t.Errorf("b: %+v, want put right", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S3: the organisation's list refused is said, and the listed repositories are still done.
|
||||
func TestOrganisationListRefusedIsSaid(t *testing.T) {
|
||||
f := newFake()
|
||||
f.refuse = func(method, path string) (int, string) {
|
||||
if strings.HasPrefix(path, "/api/v1/orgs/") {
|
||||
return 500, "down"
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"x": {}})
|
||||
if result(o, "x").Did != DidMade {
|
||||
t.Errorf("x: %+v", result(o, "x"))
|
||||
}
|
||||
refusedList := false
|
||||
for _, r := range o.Refused() {
|
||||
refusedList = refusedList || r.Step == StepList
|
||||
}
|
||||
if !refusedList {
|
||||
t.Errorf("the refused list is not in the outcome: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-2 (the operator, 2026-10-23 ~12:05): the operator's own forge login stays on main's merge whitelist beside the
|
||||
// tools' account, so the operator can always merge by hand at the forge; a rule naming the tools' account alone is put
|
||||
// right, and a second pass changes nothing.
|
||||
func TestTheOperatorsLoginStaysBesideTheTools(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule()}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
s.Mergers = []string{"jschoubben", tools, "jschoubben"}
|
||||
o := run(t, s, Value{"mesh-delivery": {}, "fresh": {}})
|
||||
for name, rule := range map[string]map[string]any{"mesh-delivery": repo.Rules[mainRule], "fresh": f.repos["novox/fresh"].Rules[mainRule]} {
|
||||
if got := strings.Join(strs(rule["merge_whitelist_usernames"]), ","); got != tools+",jschoubben" {
|
||||
t.Errorf("%s's merge whitelist is %s, want %s and jschoubben", name, got, tools)
|
||||
}
|
||||
if !requiresNone(rule) || rule["enable_push"] != false {
|
||||
t.Errorf("%s's rule: %v", name, rule)
|
||||
}
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
|
||||
t.Errorf("outcome %+v, want put right", r)
|
||||
}
|
||||
before := len(f.writes())
|
||||
run(t, s, Value{"mesh-delivery": {}, "fresh": {}})
|
||||
if w := f.writes(); len(w) != before {
|
||||
t.Errorf("a second pass wrote %v", w[before:])
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-2: the mergers are read from the module's runtime config file; a missing key is none; a name that is no forge login
|
||||
// is refused.
|
||||
func TestReadMergers(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "r.json")
|
||||
_ = os.WriteFile(p, []byte(`{"url":"","main-mergers":["jschoubben"]}`), 0o600)
|
||||
if m, err := ReadMergers(p); err != nil || strings.Join(m, ",") != "jschoubben" {
|
||||
t.Fatalf("%v %v", m, err)
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(`{"url":""}`), 0o600)
|
||||
if m, err := ReadMergers(p); err != nil || len(m) != 0 {
|
||||
t.Fatalf("%v %v", m, err)
|
||||
}
|
||||
for _, bad := range []string{`["a b"]`, `["../x"]`, `[""]`, `"jschoubben"`} {
|
||||
_ = os.WriteFile(p, []byte(`{"main-mergers":`+bad+`}`), 0o600)
|
||||
if _, err := ReadMergers(p); err == nil {
|
||||
t.Errorf("%s was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -43,6 +44,8 @@ type fakeForge struct {
|
||||
calls []string // "METHOD /path"
|
||||
// refuse answers a call with a status and a message when it returns a status above zero.
|
||||
refuse func(method, path string) (int, string)
|
||||
// pageSize is how many repositories one page of the organisation's list holds; 50 when zero.
|
||||
pageSize int
|
||||
}
|
||||
|
||||
func newFake() *fakeForge { return &fakeForge{repos: map[string]*fRepo{}} }
|
||||
@@ -84,6 +87,7 @@ var (
|
||||
branchPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/branches/([^/]+)$`)
|
||||
filesPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/contents$`)
|
||||
movePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/transfer$`)
|
||||
orgRepos = regexp.MustCompile(`^/api/v1/orgs/([^/]+)/repos$`)
|
||||
)
|
||||
|
||||
func strs(v any) []string {
|
||||
@@ -122,7 +126,8 @@ func lets(rule map[string]any, login string) bool {
|
||||
// and in the organisation the tools' team does (it is on every repository there); nobody else here.
|
||||
func reaches(full, login string) bool {
|
||||
owner := strings.SplitN(full, "/", 2)[0]
|
||||
return owner == login || (owner == org && login == tools)
|
||||
// The operator's own login is an owner of the organisation, so it reaches every repository there (ADR 0534 §2).
|
||||
return owner == login || (owner == org && (login == tools || login == "jschoubben"))
|
||||
}
|
||||
|
||||
// keepReaching drops from a rule's merge whitelist every user without access to the repository, as gitea's
|
||||
@@ -167,6 +172,31 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case p == "/api/v1/version":
|
||||
reply(200, map[string]string{"version": "1.27"})
|
||||
case orgRepos.MatchString(p) && r.Method == http.MethodGet:
|
||||
owner := orgRepos.FindStringSubmatch(p)[1]
|
||||
var names []string
|
||||
for full := range f.repos {
|
||||
if o, n, _ := strings.Cut(full, "/"); o == owner {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
size := f.pageSize
|
||||
if size == 0 {
|
||||
size = 50
|
||||
}
|
||||
if l, _ := strconv.Atoi(r.URL.Query().Get("limit")); l > 0 && l < size {
|
||||
size = l
|
||||
}
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for i := (page - 1) * size; i < len(names) && i < page*size; i++ {
|
||||
out = append(out, map[string]any{"name": names[i], "full_name": owner + "/" + names[i]})
|
||||
}
|
||||
reply(200, out)
|
||||
case p == "/api/v1/user/repos" && r.Method == http.MethodPost:
|
||||
name := fmt.Sprint(in["name"])
|
||||
if f.repos[admin+"/"+name] != nil {
|
||||
@@ -312,12 +342,3 @@ func result(o Outcome, name string) Result {
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
func has(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import "strings"
|
||||
const MergeCheck = `#!/bin/sh
|
||||
# mesh-check-toolchain: go
|
||||
#
|
||||
# This repository's own check, mesh/repo-check (novox/hq ADR 0238 §4), written with its first commit (ADR 0428 §4):
|
||||
# This repository's own check (novox/hq ADR 0238 §4), run by the checker within mesh/check (ADR 0447), written with its first commit (ADR 0428 §4):
|
||||
# every Go module in the tree is vetted, its unit tests run, and then its integration tests (-tags integration).
|
||||
# A tree with no Go module has nothing to test, and the check says so and passes.
|
||||
set -eu
|
||||
@@ -37,7 +37,7 @@ func FirstCommit(name string, r Repo) map[string]string {
|
||||
}
|
||||
readme := "# " + name + "\n\n" + purpose + "\n\n" +
|
||||
"Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: " +
|
||||
"a change reaches it by a pull request whose `mesh/repo-check` (this repository's `merge-check.sh`) and " +
|
||||
"`mesh/merge-gate` passed.\n"
|
||||
"a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto " +
|
||||
"main, this repository's `merge-check.sh` among its tests (novox/hq ADR 0447).\n"
|
||||
return map[string]string{"README.md": readme, "merge-check.sh": MergeCheck}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// again whenever the value changes: it reads the file the mesh renders from that value and, with the setup login,
|
||||
// makes every listed repository that does not exist under that login's own account, protects its main, writes
|
||||
// main's first commit, narrows main's rule and only then moves it into the organisation; one that exists only has
|
||||
// its main rule put right. A repository the value does not list is never touched. It keeps what it did, per
|
||||
// its main rule put right, requiring no status (novox/hq ADR 0534 §1). A repository the value does not list is
|
||||
// touched only to take the required statuses off a rule covering its main. It keeps what it did, per
|
||||
// repository, in the outcome file, says each change on stdout in one line and never a secret, and exits 1 when a
|
||||
// step was refused, its last stderr lines naming each (outcome.go Report): the node-engine's gate on a run-once
|
||||
// step (ADR 0136) reports it and runs it again on every apply until it passes.
|
||||
@@ -29,7 +30,8 @@ func main() {
|
||||
func runSync() int {
|
||||
forge, missing := forgehttp.FromEnv()
|
||||
file, outcome := os.Getenv("MESH_REPOSITORIES_FILE"), os.Getenv("MESH_REPOSITORIES_OUTCOME")
|
||||
for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome} {
|
||||
for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome,
|
||||
"MESH_GITEA_CONFIG_FILE": os.Getenv("MESH_GITEA_CONFIG_FILE")} {
|
||||
if v == "" {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
@@ -55,6 +57,11 @@ func runSync() int {
|
||||
_ = WriteOutcome(outcome, o)
|
||||
return Report(o, os.Stderr)
|
||||
}
|
||||
if s.Mergers, err = ReadMergers(os.Getenv("MESH_GITEA_CONFIG_FILE")); err != nil {
|
||||
o := Outcome{At: time.Now().UTC(), Org: s.Org, Failed: err.Error()}
|
||||
_ = WriteOutcome(outcome, o)
|
||||
return Report(o, os.Stderr)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
o, _ := s.Sync(ctx, value) // a forge not reachable is in o.Failed
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
|
||||
if v, err := ReadValue(filled); err != nil || len(v) != 1 || v["mesh-delivery"].Description != "d" {
|
||||
t.Errorf("the filled template reads as %v, %v; want mesh-delivery", v, err)
|
||||
}
|
||||
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "process" && has(strs(r["restart-on"]), file["id"].(string)) {
|
||||
process = r
|
||||
@@ -105,3 +106,61 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
|
||||
t.Errorf("repositories is declared unlike agent-read-teams: %v vs %v", v, teams)
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-2: the manifest declares main-mergers as a trusted list naming the operator's login by default, merges it into
|
||||
// the module's runtime config file (with url, which the TypeScript part reads), and the repositories program is given
|
||||
// that file and runs again when it changes.
|
||||
func TestManifestDeclaresTheMainMergers(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Config map[string]map[string]any `json:"config"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := m.Config[MergersKey]
|
||||
if v == nil || v["type"] != "list" || v["trusted"] != true || strings.Join(strs(v["default"]), ",") != "jschoubben" {
|
||||
t.Fatalf("config %s = %v, want a trusted list defaulting to the operator's login", MergersKey, v)
|
||||
}
|
||||
var cfg, process map[string]any
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == "runtime-config" {
|
||||
cfg = r
|
||||
}
|
||||
if r["name"] == "gitea-repositories" {
|
||||
process = r
|
||||
}
|
||||
}
|
||||
if cfg == nil || !has(strs(cfg["takes-config"]), MergersKey) || cfg["merge"] != "json" {
|
||||
t.Fatalf("runtime-config does not take %s: %v", MergersKey, cfg)
|
||||
}
|
||||
text, err := os.ReadFile("../../" + cfg["template"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "config.json")
|
||||
_ = os.WriteFile(file, text, 0o600)
|
||||
if got, err := ReadMergers(file); err != nil || strings.Join(got, ",") != "jschoubben" {
|
||||
t.Errorf("the template's mergers read as %v, %v; want the default", got, err)
|
||||
}
|
||||
env, _ := process["env"].(map[string]any)
|
||||
if env["MESH_GITEA_CONFIG_FILE"] != cfg["path"] {
|
||||
t.Errorf("the repositories program reads %v, want %v", env["MESH_GITEA_CONFIG_FILE"], cfg["path"])
|
||||
}
|
||||
if !has(strs(process["restart-on"]), "runtime-config") {
|
||||
t.Errorf("the repositories program does not run again when the mergers change: %v", process["restart-on"])
|
||||
}
|
||||
}
|
||||
|
||||
func has(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past a status refused,
|
||||
// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past it refused,
|
||||
// as every repository's main has had since 2026-10-07.
|
||||
func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
|
||||
f := newFake()
|
||||
@@ -18,7 +18,7 @@ func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1141/AC2: a narrowed main rule that still lets an administrator override is not right: it is put right, its
|
||||
// statuses kept, and a second pass changes nothing.
|
||||
// statuses taken off (ADR 0534 §1), and a second pass changes nothing.
|
||||
func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: {
|
||||
@@ -33,10 +33,8 @@ func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
|
||||
if rule["block_admin_merge_override"] != true {
|
||||
t.Errorf("the administrator's override is still allowed: %v", rule)
|
||||
}
|
||||
for _, c := range []string{"mesh/merge-gate", "mesh/repo-check", "other/x"} {
|
||||
if !has(strs(rule["status_check_contexts"]), c) {
|
||||
t.Errorf("a status was dropped: %v", rule["status_check_contexts"])
|
||||
}
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("statuses %v, want none (ADR 0534 §1)", rule["status_check_contexts"])
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
|
||||
t.Errorf("outcome %+v, want put right", r)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,9 +27,12 @@ const (
|
||||
MainBranch = "main"
|
||||
)
|
||||
|
||||
// RequiredStatuses are what main requires in every listed repository from its first commit (ADR 0428 §5): the
|
||||
// repository's own check and the module graph's gate. A status already on the rule is kept beside them.
|
||||
var RequiredStatuses = []string{"mesh/merge-gate", "mesh/repo-check"}
|
||||
// noStatus is what main's rule says of statuses: none is required to merge (novox/hq ADR 0534 §1, the operator,
|
||||
// 2026-10-23). The checks still run and report; mesh-delivery asks the operator's Approve on a passing verdict and the
|
||||
// merge tool judges it at the merge (ADR 0534 §3, §4). The forge enforces none of them.
|
||||
func noStatus() map[string]any {
|
||||
return map[string]any{"enable_status_check": false, "status_check_contexts": []string{}}
|
||||
}
|
||||
|
||||
// The steps of one repository, as an outcome names a refused one (ADR 0428 §6).
|
||||
const (
|
||||
@@ -42,6 +46,10 @@ const (
|
||||
// StepRuleAfterMove reads main's rule back once the repository is in the organisation and puts it right: the
|
||||
// forge drops from a merge whitelist a user without access, and under the setup account the tools' have none.
|
||||
StepRuleAfterMove = "rule-after-move"
|
||||
// StepList reads the organisation's repositories, for those the value does not list (issue 1301).
|
||||
StepList = "list"
|
||||
// StepNoStatus takes the required statuses off main's rule of a repository the value does not list (ADR 0534 §1).
|
||||
StepNoStatus = "no-status"
|
||||
)
|
||||
|
||||
// What a pass did with one repository.
|
||||
@@ -64,6 +72,37 @@ type Repo struct {
|
||||
// Value is the config value: each repository of the organisation by name.
|
||||
type Value map[string]Repo
|
||||
|
||||
// MergersKey is the config value naming the forge logins that merge into main beside the tools' account (ADR 0534 §2).
|
||||
const MergersKey = "main-mergers"
|
||||
|
||||
// loginName is a forge login: what a merge whitelist may name.
|
||||
var loginName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$`)
|
||||
|
||||
// ReadMergers reads the main-mergers value from the module's runtime config file, which the mesh merges the value into
|
||||
// (module.json runtime-config); a file without the key names none.
|
||||
func ReadMergers(file string) ([]string, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the module's config file cannot be read (%s): %w", file, err)
|
||||
}
|
||||
var whole map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &whole); err != nil {
|
||||
return nil, fmt.Errorf("the module's config file %s is not JSON: %w", file, err)
|
||||
}
|
||||
var out []string
|
||||
if r, ok := whole[MergersKey]; ok && string(r) != "null" {
|
||||
if err := json.Unmarshal(r, &out); err != nil {
|
||||
return nil, fmt.Errorf("%s in %s is not a list of forge logins: %w", MergersKey, file, err)
|
||||
}
|
||||
}
|
||||
for _, n := range out {
|
||||
if !loginName.MatchString(n) || strings.Contains(n, "..") {
|
||||
return nil, fmt.Errorf("%s in %s lists %q, which is not a forge login", MergersKey, file, n)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadValue reads the file the mesh renders from the config value. A file without the key is the empty value,
|
||||
// which makes nothing and touches nothing.
|
||||
func ReadValue(file string) (Value, error) {
|
||||
@@ -124,8 +163,11 @@ type Syncer struct {
|
||||
Forge forgehttp.Client // the setup login's
|
||||
Org string // the organisation, novox
|
||||
Admin string // the setup login, under whose own account a repository is made
|
||||
Tools string // the tools' account, the only one that merges into main
|
||||
Log func(format string, a ...any)
|
||||
Tools string // the tools' account, which merges into main for mesh-delivery
|
||||
// Mergers are the forge logins that merge into main beside the tools' account: the operator's own, so the operator
|
||||
// can always merge by hand at the forge (ADR 0534 §2; the gitea module's config value main-mergers).
|
||||
Mergers []string
|
||||
Log func(format string, a ...any)
|
||||
// WaitFor is how long to wait for the forge to answer; none when zero.
|
||||
WaitFor, Poll time.Duration
|
||||
}
|
||||
@@ -157,9 +199,70 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) {
|
||||
}
|
||||
out.Repositories = append(out.Repositories, r)
|
||||
}
|
||||
for _, r := range s.switchUnlisted(ctx, v) {
|
||||
if r.Did == DidRefused {
|
||||
s.say("refused: %s/%s at the step %s: %s", s.Org, r.Name, r.Step, r.Said)
|
||||
} else {
|
||||
s.say("%s: %s/%s (main requires no status)", r.Did, s.Org, r.Name)
|
||||
}
|
||||
out.Repositories = append(out.Repositories, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// orgPage is how many repositories one read of the organisation's list asks for.
|
||||
const orgPage = 50
|
||||
|
||||
// switchUnlisted takes every required status off a rule covering main in every repository of the organisation the
|
||||
// value does not list (ADR 0534 §1): the forge requires no status to merge anywhere, with no list kept by hand. Only
|
||||
// the statuses are sent; nothing else of a rule the value does not hold is changed (ADR 0428 §7). A main whose rules
|
||||
// require no status gets no call. It answers only what it did: a repository put right, or refused.
|
||||
func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
|
||||
var names []string
|
||||
for page := 1; ; page++ {
|
||||
var repos []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
q := "?limit=" + fmt.Sprint(orgPage) + "&page=" + fmt.Sprint(page)
|
||||
if err := s.Forge.GetJSON(ctx, "/api/v1/orgs/"+url.PathEscape(s.Org)+"/repos"+q, &repos); err != nil {
|
||||
return []Result{refused("(the organisation's repositories)", StepList, err)}
|
||||
}
|
||||
for _, r := range repos {
|
||||
if _, listed := v[r.Name]; !listed && r.Name != "" {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
}
|
||||
if len(repos) == 0 || page > 1000 {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
var out []Result
|
||||
for _, n := range names {
|
||||
rules, err := s.rules(ctx, s.Org, n)
|
||||
if err != nil {
|
||||
out = append(out, refused(n, StepRead, err))
|
||||
continue
|
||||
}
|
||||
var did *Result
|
||||
for _, r := range rules {
|
||||
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || len(required(r)) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.Forge.SendJSON(ctx, http.MethodPatch,
|
||||
repoAPI(s.Org, n)+"/branch_protections/"+url.PathEscape(ruleName(r)), noStatus(), nil); err != nil {
|
||||
did = &Result{Name: n, Did: DidRefused, Step: StepNoStatus, Said: err.Error()}
|
||||
break
|
||||
}
|
||||
did = &Result{Name: n, Did: DidPutRight}
|
||||
}
|
||||
if did != nil {
|
||||
out = append(out, *did)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitForForge is cmd/agent-teams' (teams.go waitForForge): the forge starting with the module answers late.
|
||||
func (s *Syncer) waitForForge(ctx context.Context) error {
|
||||
deadline := time.Now().Add(s.WaitFor)
|
||||
@@ -243,9 +346,9 @@ func (s *Syncer) one(ctx context.Context, name string, r Repo) Result {
|
||||
return refused(name, StepFirstCommit, err)
|
||||
}
|
||||
}
|
||||
if rule == nil || !isRight(rule, s.Tools) {
|
||||
if rule == nil || !isRight(rule, s.Tools, s.Mergers) {
|
||||
if err := s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(s.Admin, name)+"/branch_protections/"+MainBranch,
|
||||
narrowed(rule, s.Tools), nil); err != nil {
|
||||
narrowed(s.Tools, s.Mergers), nil); err != nil {
|
||||
return refused(name, StepNarrow, err)
|
||||
}
|
||||
}
|
||||
@@ -309,80 +412,66 @@ func strList(v any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// statuses is what a rule requires, with RequiredStatuses added and none dropped.
|
||||
func statuses(rules ...rule) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(s string) {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
// required is what a rule requires: its statuses when it checks them, else none.
|
||||
func required(r rule) []string {
|
||||
if r == nil || r["enable_status_check"] != true {
|
||||
return nil
|
||||
}
|
||||
for _, r := range rules {
|
||||
if r != nil && r["enable_status_check"] == true {
|
||||
for _, s := range strList(r["status_check_contexts"]) {
|
||||
add(s)
|
||||
}
|
||||
}
|
||||
return strList(r["status_check_contexts"])
|
||||
}
|
||||
|
||||
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges, no
|
||||
// administrator merging past the rule, as every repository's main has been since 2026-10-07 (issue 1141), and no
|
||||
// status required (ADR 0534 §1). A field the forge keeps beside these is left as it is.
|
||||
func narrowed(toolsAccount string, mergers []string) map[string]any {
|
||||
body := map[string]any{
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []string{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": whitelist(toolsAccount, mergers),
|
||||
"block_admin_merge_override": true,
|
||||
}
|
||||
for _, s := range RequiredStatuses {
|
||||
add(s)
|
||||
for k, v := range noStatus() {
|
||||
body[k] = v
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// whitelist is main's merge whitelist: the tools' account first, then each other merger once, in the value's order.
|
||||
func whitelist(toolsAccount string, mergers []string) []string {
|
||||
out := []string{toolsAccount}
|
||||
for _, m := range mergers {
|
||||
if !slices.Contains(out, m) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges,
|
||||
// requiring the statuses (ADR 0428 §5), and no administrator merging past them, as every repository's main has been
|
||||
// since 2026-10-07 (issue 1141). A field the forge keeps beside these is left as it is.
|
||||
func narrowed(r rule, toolsAccount string, also ...rule) map[string]any {
|
||||
return map[string]any{
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []string{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": []string{toolsAccount},
|
||||
"enable_status_check": true, "status_check_contexts": statuses(append([]rule{r}, also...)...),
|
||||
"block_admin_merge_override": true,
|
||||
}
|
||||
}
|
||||
|
||||
// isRight says whether a rule already is narrowed and requires both statuses.
|
||||
func isRight(r rule, toolsAccount string) bool {
|
||||
// isRight says whether a rule already is narrowed and requires no status.
|
||||
func isRight(r rule, toolsAccount string, mergers []string) bool {
|
||||
if r["enable_push"] != false || r["enable_push_whitelist"] == true || r["enable_merge_whitelist"] != true {
|
||||
return false
|
||||
}
|
||||
if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount {
|
||||
if m := strList(r["merge_whitelist_usernames"]); !slices.Equal(m, whitelist(toolsAccount, mergers)) {
|
||||
return false
|
||||
}
|
||||
return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"])) &&
|
||||
return r["enable_status_check"] != true && len(strList(r["status_check_contexts"])) == 0 &&
|
||||
r["block_admin_merge_override"] == true
|
||||
}
|
||||
|
||||
func hasAll(have []string) bool {
|
||||
for _, want := range RequiredStatuses {
|
||||
found := false
|
||||
for _, h := range have {
|
||||
found = found || h == want
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires.
|
||||
// A repository with no rule named main gets one, carrying what a glob rule covering main asks (client.ts
|
||||
// ensureMainMerges, issue 948). It never takes a status away and never unprotects (ADR 0428 §5, §7).
|
||||
// putRight makes main's rule in an organisation's repository the narrowed one, requiring no status (ADR 0534 §1).
|
||||
// A repository with no rule named main gets one, carrying the approvals a glob rule covering main asks (client.ts
|
||||
// ensureMainMerges, issue 948). It never unprotects (ADR 0428 §7).
|
||||
func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) {
|
||||
rules, err := s.rules(ctx, owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if own := ruleNamed(rules, MainBranch); own != nil {
|
||||
if isRight(own, s.Tools) {
|
||||
if isRight(own, s.Tools, s.Mergers) {
|
||||
return false, nil
|
||||
}
|
||||
return true, s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(owner, name)+"/branch_protections/"+MainBranch,
|
||||
narrowed(own, s.Tools), nil)
|
||||
narrowed(s.Tools, s.Mergers), nil)
|
||||
}
|
||||
var covering []rule
|
||||
for _, r := range rules {
|
||||
@@ -390,7 +479,7 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error)
|
||||
covering = append(covering, r)
|
||||
}
|
||||
}
|
||||
body := narrowed(nil, s.Tools, covering...)
|
||||
body := narrowed(s.Tools, s.Mergers)
|
||||
body["rule_name"] = MainBranch
|
||||
approvals := 0.0
|
||||
for _, r := range covering {
|
||||
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
const mainRule = "main"
|
||||
|
||||
// 1124/AC2: a listed repository that does not exist is made under the setup account, given main's rule letting that
|
||||
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/repo-check (and
|
||||
// mesh/merge-gate, 1124/AC5), and only then moved into the organisation. Nothing reaches the organisation before the
|
||||
// narrowed rule, and nothing else is pushed to main.
|
||||
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/check (1124/AC5
|
||||
// as issue 1301 changes it), and only then moved into the organisation. Nothing reaches the organisation before the
|
||||
// narrowed rule, and nothing else is pushed to main. Main requires no status (ADR 0534 §1).
|
||||
func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
@@ -66,9 +66,8 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
if rule["enable_merge_whitelist"] != true || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools {
|
||||
t.Errorf("main's merge whitelist is not the tools' account alone: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") {
|
||||
t.Errorf("main does not require mesh/repo-check and mesh/merge-gate (1124/AC5): %v", rule)
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("main requires a status (ADR 0534 §1): %v", rule)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidMade || r.Step != "" {
|
||||
t.Errorf("outcome %+v, want made", r)
|
||||
@@ -87,7 +86,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
|
||||
// 1124/AC2: the forge drops from a merge whitelist a user without access to the repository, and under the setup
|
||||
// account the tools' account has none; so once the repository is in the organisation its main rule is read back and
|
||||
// put right: the tools' account the only one that merges, both statuses required.
|
||||
// put right: the tools' account the only one that merges, no status required (ADR 0534 §1).
|
||||
func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
@@ -103,9 +102,8 @@ func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
|
||||
if rule["enable_push"] != false {
|
||||
t.Errorf("after the move main takes a push: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") {
|
||||
t.Errorf("after the move main does not require both statuses: %v", rule)
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("after the move main requires a status (ADR 0534 §1): %v", rule)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidMade {
|
||||
t.Errorf("outcome %+v, want made", r)
|
||||
@@ -136,7 +134,7 @@ func TestAdminRuleLetsTheSetupLoginAlonePush(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1124/AC3: a listed repository that exists in the organisation is not made again, only its main rule put right;
|
||||
// a rule still letting the setup login push is narrowed, its statuses kept (1124/AC5).
|
||||
// a rule still letting the setup login push is narrowed, and requires no status (ADR 0534 §1).
|
||||
func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
|
||||
f := newFake()
|
||||
hq := f.add("novox/hq", map[string]map[string]any{mainRule: {
|
||||
@@ -156,15 +154,11 @@ func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
|
||||
if rule["enable_push"] != false || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools {
|
||||
t.Errorf("hq's rule not narrowed: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
for _, c := range []string{"other/x", "mesh/merge-gate", "mesh/repo-check"} {
|
||||
if !has(checks, c) {
|
||||
t.Errorf("hq's rule lacks %s: %v", c, checks)
|
||||
}
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("hq's rule requires %v, want none (ADR 0534 §1)", rule["status_check_contexts"])
|
||||
}
|
||||
made := bare.Rules[mainRule]
|
||||
if made == nil || made["enable_push"] != false || !has(strs(made["status_check_contexts"]), "mesh/repo-check") ||
|
||||
!has(strs(made["status_check_contexts"]), "mesh/merge-gate") {
|
||||
if made == nil || made["enable_push"] != false || !requiresNone(made) {
|
||||
t.Errorf("bare's rule: %v", made)
|
||||
}
|
||||
if hq.Commits != 0 || bare.Commits != 0 {
|
||||
@@ -214,33 +208,33 @@ func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC5: putting a rule right adds a missing one of mesh/repo-check and mesh/merge-gate and keeps every status
|
||||
// already there; no pass drops one.
|
||||
func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
|
||||
// ADR 0534 §1: putting a rule right takes every required status off it, whichever they are, and a rule already right
|
||||
// is not written.
|
||||
func TestStatusesAreTakenOff(t *testing.T) {
|
||||
f := newFake()
|
||||
only := f.add("novox/only", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/repo-check"},
|
||||
}}, "main")
|
||||
more := f.add("novox/more", map[string]map[string]any{mainRule: {
|
||||
right := f.add("novox/right", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "mesh/delivery"},
|
||||
"enable_status_check": false, "status_check_contexts": []any{},
|
||||
"block_admin_merge_override": true, // right since issue 1141
|
||||
}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"only": {}, "more": {}})
|
||||
if c := strs(only.Rules[mainRule]["status_check_contexts"]); !has(c, "mesh/merge-gate") || !has(c, "mesh/repo-check") {
|
||||
t.Errorf("only: %v", c)
|
||||
o := run(t, s, Value{"only": {}, "right": {}})
|
||||
if !requiresNone(only.Rules[mainRule]) {
|
||||
t.Errorf("only: %v", only.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 3 || !has(c, "mesh/delivery") {
|
||||
t.Errorf("more lost a status: %v", c)
|
||||
if !requiresNone(right.Rules[mainRule]) {
|
||||
t.Errorf("right: %v", right.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
for _, w := range f.writes() {
|
||||
if strings.Contains(w, "/more/") {
|
||||
if strings.Contains(w, "/right/") {
|
||||
t.Errorf("a right rule was written: %s", w)
|
||||
}
|
||||
}
|
||||
if result(o, "more").Did != DidUnchanged || result(o, "only").Did != DidPutRight {
|
||||
if result(o, "right").Did != DidUnchanged || result(o, "only").Did != DidPutRight {
|
||||
t.Errorf("outcome %+v", o)
|
||||
}
|
||||
}
|
||||
@@ -314,7 +308,8 @@ func TestRefusedStepStopsThatRepositoryOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC7: a repository taken out of the value gets no call at all.
|
||||
// 1124/AC7: a repository taken out of the value is not changed: its rules are read (issue 1301 switches one whose
|
||||
// main requires mesh/merge-gate), nothing is written, and one still under the setup account is not asked about.
|
||||
func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
|
||||
f := newFake()
|
||||
f.add("novox/old", map[string]map[string]any{mainRule: {"enable_push": true}}, "main")
|
||||
@@ -323,7 +318,7 @@ func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
|
||||
run(t, s, Value{})
|
||||
run(t, s, Value{"new": {}})
|
||||
for _, c := range f.all() {
|
||||
if strings.Contains(c, "old") {
|
||||
if strings.Contains(c, "older") || (strings.Contains(c, "old") && !strings.HasPrefix(c, "GET ")) {
|
||||
t.Errorf("a repository out of the value was asked: %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import { promisify } from "node:util";
|
||||
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import type { CommitStatus } from "./client.js";
|
||||
import { checkName } from "./guard.js";
|
||||
import { CHECK_STATUS } from "./pulls.js";
|
||||
|
||||
export { CHECK_STATUS };
|
||||
|
||||
/** How a command is run: docker on the forge's machine, or a test's. */
|
||||
export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>;
|
||||
@@ -91,11 +94,18 @@ const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
/** The merge check's contexts (pulls.ts): a branch's protection may require them. */
|
||||
const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]);
|
||||
|
||||
|
||||
/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a
|
||||
* bounded description. */
|
||||
export function deliveryStatus(context: string, state: string, description: string, target?: string) {
|
||||
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
|
||||
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
|
||||
// mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any
|
||||
// state, as issue 293 closed for the two before it. No tool writes it, since a tool is told no caller (R4e finding K1):
|
||||
// check.ts writes it from the checker's owed state (ADR 0521).
|
||||
if (context === CHECK_STATUS) {
|
||||
throw new Error(`${CHECK_STATUS} is the checker's status: it is written from mesh-delivery-checker.owed alone, never by a tool`);
|
||||
}
|
||||
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
|
||||
// them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq
|
||||
// issue 293). Pending is no verdict: it is what this holder says on every new head until the verdict comes,
|
||||
@@ -126,7 +136,7 @@ export interface StatusWriters {
|
||||
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||
return {
|
||||
name: "gitea_commit_status",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: no tool writes it: it is written from the checker's own state. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
{}
|
||||
{"main-mergers": ["jschoubben"]}
|
||||
|
||||
@@ -212,7 +212,8 @@ export class GateWriter {
|
||||
let since = h.pendingAt;
|
||||
if (since === undefined) {
|
||||
const gate = await this.forge.gateOf(owner, repo, sha);
|
||||
if (gate && gate.state !== "pending") return;
|
||||
// No gate on the forge: none was written (its base does not require it, issue 1301), and nothing is said.
|
||||
if (!gate || gate.state !== "pending") return;
|
||||
since = gate?.updated_at ? Date.parse(gate.updated_at) : undefined;
|
||||
}
|
||||
if (since !== undefined && at.getTime() < since - CLOCK_SLACK_MS) return;
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
module gitea
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.51.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user