Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001f4f23aa | ||
|
|
11b3e6a95d | ||
|
|
277a5bd34d | ||
|
|
f80d5d3d86 | ||
|
|
5993dab6d1 | ||
|
|
c285af02f9 | ||
|
|
49e853986b | ||
|
|
5a2eb2b480 | ||
|
|
bec931e72e | ||
|
|
fa512c8637 | ||
|
|
ec821f6199 | ||
|
|
c07cdaf2f4 | ||
|
|
de72cb9a11 | ||
|
|
4b226aa78f | ||
|
|
02645b5a0f | ||
|
|
18e2e05bb5 | ||
|
|
0b0c669b49 | ||
|
|
c527fc7222 | ||
|
|
2a1ad896fa | ||
|
|
be77f66563 | ||
|
|
362a3f1378 | ||
|
|
03da552cb3 | ||
|
|
4e0c57771e | ||
|
|
b31c4580aa | ||
|
|
47eb100992 | ||
|
|
7359d187db | ||
|
|
47b1dcf08f | ||
|
|
e32d777530 | ||
|
|
472d5f3144 | ||
|
|
2fd5ffdd62 | ||
|
|
3f7957e169 | ||
|
|
a5d0e64200 | ||
|
|
9fdd33b03e | ||
|
|
2e8c2a7f4c | ||
|
|
d688f106e9 | ||
|
|
cfe6094f46 | ||
|
|
bee33f4715 | ||
|
|
2ecc94062e | ||
|
|
d48365aed0 | ||
|
|
7a8e9f9706 | ||
|
|
d7a758894a | ||
|
|
04de3e5da2 | ||
|
|
9f92233a28 | ||
|
|
8ad9372b19 | ||
|
|
3236cbad43 | ||
|
|
a262c7dbe0 | ||
|
|
05b6783103 | ||
|
|
56de5ba840 | ||
|
|
43eef46c41 | ||
|
|
cadd7b1b2f | ||
|
|
062c29c3a2 | ||
|
|
a37d2622ed | ||
|
|
ba398e2460 | ||
|
|
82f10c3bc9 | ||
|
|
7e99c354ac | ||
|
|
8e81d87610 | ||
|
|
db1007ffdd | ||
|
|
981a35b598 | ||
|
|
b0cbc4e6ac | ||
|
|
a2759ec92e | ||
|
|
f178323b57 | ||
|
|
684acb922d | ||
|
|
e78e881ecb | ||
|
|
68d65e6bbf | ||
|
|
823d52051f | ||
|
|
b4310dfb8e | ||
|
|
e44cbaf57f | ||
|
|
1f70c4c0aa | ||
|
|
5ecda46124 | ||
|
|
aa8e9afc7c | ||
|
|
54a8afac73 | ||
|
|
43de3c937d | ||
|
|
47a66e3c0d | ||
|
|
81ec7bd7de | ||
|
|
e40dd0f56a |
@@ -571,3 +571,8 @@ type ProofAnswer struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259
|
||||
// §2 to admit a node's engine as an asker): `node-engine-<node>`, the last token of the by-caller subjects granted to that
|
||||
// node's engine account alone, so no engine acts on another node's warrant.
|
||||
func EngineAsker(node string) string { return "node-engine-" + node }
|
||||
|
||||
@@ -10,7 +10,10 @@ import (
|
||||
|
||||
// 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls
|
||||
// nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing.
|
||||
func TestNodeCapacity(t *testing.T) {
|
||||
// ADR 0553 §4: a step's threads are the composer's share over the module's own capacity; without the word, none. And
|
||||
// issue 1806's follow-up: the capacity judged by is the configured one bounded by the build slice's cores over the cores
|
||||
// a step needs (the three build nodes as they stand on 2026-10-26), and cores-per-step is read from the settings file.
|
||||
func TestNodeCapacityAndLimits(t *testing.T) {
|
||||
if got := NodeCapacity("", DefaultCapacities, "novox", nil)(); got != 0 {
|
||||
t.Errorf("novox = %d by default, want 0", got)
|
||||
}
|
||||
@@ -37,12 +40,6 @@ func TestNodeCapacity(t *testing.T) {
|
||||
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), DefaultCapacities, "shanks", nil)(); got != 0 {
|
||||
t.Errorf("a missing file = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0553 §4: a step's threads are the composer's share over the module's own capacity; without the word, none. And
|
||||
// issue 1806's follow-up: the capacity judged by is the configured one bounded by the build slice's cores over the cores
|
||||
// a step needs (the three build nodes as they stand on 2026-10-26), and cores-per-step is read from the settings file.
|
||||
func TestNodeLimitsAndTheCPUBound(t *testing.T) {
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "")
|
||||
if l := NodeLimits(3); l.Threads != 0 || l.Slots != 3 {
|
||||
t.Errorf("without the word: %+v", l)
|
||||
@@ -71,7 +68,7 @@ func TestNodeLimitsAndTheCPUBound(t *testing.T) {
|
||||
t.Errorf("%s: %+v, want effective %d, by CPU %d", c.name, got, c.effective, c.byCPU)
|
||||
}
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "settings.json")
|
||||
file = filepath.Join(t.TempDir(), "settings.json")
|
||||
var said []string
|
||||
cores := NodeCoresPerStep(file, 6, func(f string, a ...any) { said = append(said, f) })
|
||||
for _, c := range []struct {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# The conformance kit's captured runner output
|
||||
|
||||
Every file here is the real output of a test runner, never written by hand (issue 1901: a fixture that agrees with the
|
||||
mistake proves the mistake). The toolchains are the build nodes' own, as `mesh-build-go.build_go_toolchains` and
|
||||
`mesh-build-node.build_node_toolchains` reported them on 2026-10-26: go1.27.3-X:nodwarf5 on every build node, node
|
||||
v26.12.1 (npm 12.2.1) on shanks, novox and g14, and typescript 5.9.3 from mesh-build-node's toolchain/package.json.
|
||||
To capture a case again, make its tiny project in a fresh directory as below and run the command shown; the kit's case
|
||||
table (conformance.go) says what each failure must say.
|
||||
|
||||
## go/ — `go test -json`
|
||||
|
||||
- `passfirst.jsonl` and `subtests.jsonl` (2026-10-26, go1.27.3-X:nodwarf5, for issue 1901's thorough fix): in
|
||||
`/tmp/1901k/go/<case>`, `GOTOOLCHAIN=local GOFLAGS=-mod=mod go test -json -count=1 ./... > ../<case>.jsonl 2>&1`.
|
||||
- passfirst: module `example.com/passfirst`, `go 1.26`. Package `a` has `TestTheFirstPackagePasses` (empty). Package `b`
|
||||
has `TestTheSecondPackagePassesFirst` (empty) and `TestTheSecondPackageFails`, which calls
|
||||
`t.Logf("judged against the seat as it stands")` then `t.Fatalf("the seat has 2 holders, want 1")`: a passing package
|
||||
and a passing test before the failing one.
|
||||
- subtests: module `example.com/subtests`; `TestTheTable` runs `t.Run("passes", func(t *testing.T) {})` and
|
||||
`t.Run("fails", func(t *testing.T) { t.Errorf("got 3 machines, want 4") })`: the parent fails only by its subtest.
|
||||
- `subpanic.jsonl` (2026-10-26, go1.27.3-X:nodwarf5, review round 1 of issue 1901's fix), the same way: module
|
||||
`example.com/subpanic`; `TestTable` runs `t.Run("passes", func(t *testing.T) {})` and `t.Run("panics", ...)`, which
|
||||
writes to a nil map. go test -json files the subtest's panic under the parent `TestTable`.
|
||||
- `logthenfatal.jsonl`, `panicking`, `timeout`, `deeptimeout`, `buildfail`, `exiting`, `killed`, `passthenfail`,
|
||||
`mainexit`, `race`, `racemain` were captured for issue 1901's first and third meetings (2026-10-23 and 2026-10-25,
|
||||
go1.27.3) in mesh-catalog's mesh-build-go (`testdata/captured/go-test-json-*.jsonl`, named there `go-test-json-pkg-<case>`
|
||||
and `go-test-json-log-then-fatal`) and moved here unchanged. From that README: each is the standard output and error of
|
||||
`go test -json -count=1 ./...` (the timeouts with `-timeout 2s`; race and racemain with `-race`, CGO_ENABLED=1) in a
|
||||
throwaway one-file module that fails without a test's own assertion: panicking (a nil map write in TestPanics), timeout
|
||||
(TestWaitsForever sleeps past the timeout), deeptimeout (two parallel tests and sixty blocked goroutines past the
|
||||
timeout, so the goroutine dump pushes the panic line out of the last 4 KB), buildfail (a type error in a test file),
|
||||
exiting (os.Exit(3) in TestExits), killed (TestKilled sends itself SIGKILL), passthenfail (TestMain panics in a
|
||||
goroutine after m.Run passed), mainexit (TestMain exits 2 after m.Run passed: go test prints "PASS" and then
|
||||
"FAIL <pkg>"), race (TestRacesOnTheSeat writes a package variable from itself and a goroutine), racemain (TestMain does
|
||||
so after m.Run passed). logthenfatal is a test `TestThree` in package `x` that logs a line with t.Logf and then fails
|
||||
with a multi-line t.Fatalf naming three subscriptions.
|
||||
|
||||
## node/ — `npm test` running `node --test` under the TAP reporter
|
||||
|
||||
Captured 2026-10-26 with node v26.12.1 and npm 12.2.1 (`/usr/bin/node`, `/usr/bin/npm`), in `/tmp/1901k/node/<case>`, the
|
||||
way mesh-build-node runs a test script:
|
||||
|
||||
PATH=/usr/bin:$PATH NODE_OPTIONS="--test-reporter=tap --test-reporter-destination=/tmp/1901k/node/<case>.tap" \
|
||||
npm test > ../<case>.stdout 2> ../<case>.stderr
|
||||
|
||||
`<case>.tap` is the report file, `<case>.console` the stdout followed by the stderr. Each project's package.json is
|
||||
`{"name":"<case>","private":true,"type":"module","scripts":{"test":"node --test --experimental-strip-types 'test/*.test.ts'"}}`
|
||||
(the script every TypeScript package of the catalogue and the SDK runs), with these test files:
|
||||
|
||||
- two-files: `test/a.test.ts` holds `test('passes in the first file', () => {})`; `test/b.test.ts` holds
|
||||
`test('passes in the second file first', () => {})` and `test('fails in the second file', ...)`, which logs
|
||||
`judged against the seat as it stands` and asserts `assert.strictEqual(1 + 1, 3, 'one plus one is not three')`.
|
||||
- suite: `test/a.test.ts` has `describe('the seat', ...)` with `it('passes inside', () => {})` and `it('fails inside', ...)`
|
||||
asserting `assert.deepStrictEqual({ holders: 2 }, { holders: 1 })`.
|
||||
- crash: `test/a.test.ts` holds `test('passes before the crash', () => {})`; `test/b.test.ts` holds
|
||||
`test('throws after it returned', ...)`, which throws `new Error('the bus closed under the test')` in a `setTimeout` of
|
||||
1 ms, and `test('waits after the throw', ...)`, which waits 50 ms: node fails the file with an uncaught exception.
|
||||
- logcrash (review round 1): `test/a.test.ts` holds `test('passes and logs', ...)`, which logs
|
||||
`retrying after Error: the store was not ready yet`; `test/b.test.ts` is crash's. A passing test's console line
|
||||
holding "Error" comes before the crash's own.
|
||||
- timeout: `test/a.test.ts` holds `test('passes before the wait', () => {})` and
|
||||
`test('waits for an answer that never comes', { timeout: 200 }, async () => { await new Promise(() => {}); })`.
|
||||
- syntax: `test/a.test.ts` holds `test('passes in the first file', () => {})`; `test/b.test.ts` is
|
||||
`import test from 'node:test';` and `test('never loads', () => {` (cut off): the file does not load.
|
||||
|
||||
## tsc/ — the type-check
|
||||
|
||||
`typeerror.console` (2026-10-26, typescript 5.9.3 under node v26.12.1): in `/tmp/1901k/tsc/typeerror`, `ok.ts` is
|
||||
`export const holders: number = 1;` and `seat.ts` is `import { holders } from './ok.js'; export const name: string = holders;`;
|
||||
the command is mesh-build-node's type-check line without a tsconfig.json:
|
||||
|
||||
node <typescript>/bin/tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . ok.ts seat.ts
|
||||
|
||||
and the file is its stdout followed by its stderr.
|
||||
@@ -0,0 +1,6 @@
|
||||
{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-output","Output":"# example.com/buildfail [example.com/buildfail.test]\n"}
|
||||
{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-output","Output":"./p_test.go:5:40: cannot use \"seat\" (untyped string constant) as int value in variable declaration\n"}
|
||||
{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-fail"}
|
||||
{"Time":"2026-10-25T15:34:33.261137737+01:00","Action":"start","Package":"example.com/buildfail"}
|
||||
{"Time":"2026-10-25T15:34:33.261255019+01:00","Action":"output","Package":"example.com/buildfail","Output":"FAIL\texample.com/buildfail [build failed]\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.261268099+01:00","Action":"fail","Package":"example.com/buildfail","Elapsed":0,"FailedBuild":"example.com/buildfail [example.com/buildfail.test]"}
|
||||
@@ -0,0 +1,417 @@
|
||||
{"Time":"2026-10-25T15:35:18.991630993+01:00","Action":"start","Package":"example.com/deeptimeout"}
|
||||
{"Time":"2026-10-25T15:35:18.993895188+01:00","Action":"run","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"}
|
||||
{"Time":"2026-10-25T15:35:18.993963909+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== RUN TestHoldsTheSeat\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:35:18.99399484+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== PAUSE TestHoldsTheSeat\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:35:18.9939982+01:00","Action":"pause","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"}
|
||||
{"Time":"2026-10-25T15:35:18.99400173+01:00","Action":"run","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"}
|
||||
{"Time":"2026-10-25T15:35:18.99400456+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== RUN TestWaitsForTheBus\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:35:18.99400838+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== PAUSE TestWaitsForTheBus\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:35:18.99401133+01:00","Action":"pause","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"}
|
||||
{"Time":"2026-10-25T15:35:18.99401441+01:00","Action":"cont","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"}
|
||||
{"Time":"2026-10-25T15:35:18.99401704+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== CONT TestHoldsTheSeat\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:35:18.994074141+01:00","Action":"cont","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"}
|
||||
{"Time":"2026-10-25T15:35:18.994102612+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== CONT TestWaitsForTheBus\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:35:21.00935195+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"panic: test timed out after 2s\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00938383+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\trunning tests:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009394391+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t\tTestHoldsTheSeat (2s)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009398021+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t\tTestWaitsForTheBus (2s)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009401801+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009406481+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 81 [running]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009409911+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.(*M).startAlarm.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009413361+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2961 +0x34a\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009418341+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by time.goFunc\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009421781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/time/sleep.go:182 +0x2d\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009424991+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009428341+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 1 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009431721+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009441811+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2144 +0x425\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009445511+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702008, 0x282e9646bc8)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009449641+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2201 +0x123\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009453471+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.runTests({0x55a45d, 0x17}, {0x55a45d, 0x17}, 0x282e95940f0, {0x6f19f0, 0x2, 0x2}, {0xc2afa2ca3b3c0c8d, 0x7737f168, ...})\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009462092+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2742 +0x510\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009467222+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.(*M).Run(0x282e96581e0)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009470662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2602 +0x6af\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009473692+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"main.main()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009476942+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t_testmain.go:48 +0x9b\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009480092+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009483242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 7 [sleep]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009486502+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"time.Sleep(0xdf8475800)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009490682+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009493812+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat(0x282e9702248?)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009498842+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:14 +0x9e\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009502242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702248, 0x6d5e78)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009505742+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009509292+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by testing.(*T).Run in goroutine 1\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009512752+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009515792+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009518623+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 8 [sleep]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009521303+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"time.Sleep(0xdf8475800)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009524423+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009531403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestWaitsForTheBus(0x282e9702488?)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009534943+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:19 +0x25\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009538403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702488, 0x6d5e80)\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009541953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009545233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by testing.(*T).Run in goroutine 1\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009548323+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009551343+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009554263+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 9 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009557523+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009561063+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009564513+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009571113+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009574233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009577433+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 10 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009580823+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009584174+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009587504+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009590974+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009594004+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009597184+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 11 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009600394+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009603874+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009610954+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009614714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009618104+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009621364+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 12 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009624704+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009628324+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009631954+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009635404+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009638734+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009641904+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 13 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009645325+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009648645+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009652065+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009655625+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009658885+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009663385+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 14 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009666735+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009670235+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009676385+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009680205+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009683315+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009686475+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 15 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009689785+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009697235+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009700795+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009704985+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009708085+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009711346+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 16 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009714586+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009717946+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009721676+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009725626+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009728866+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009732106+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 17 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009735376+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009738956+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009742326+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009746436+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009750666+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009754126+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 18 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009758286+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009761846+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009765776+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009769816+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009773167+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009780507+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 19 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009784207+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009788007+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009791627+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009796347+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009799427+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009802937+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 20 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009806667+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009809987+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009813827+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009818087+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009821397+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009824227+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 21 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009827607+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009831307+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009835167+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009839528+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009842728+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009845888+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 22 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009849168+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009852548+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009855958+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009863648+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009866938+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009870088+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 23 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009879828+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009883728+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009887608+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009891548+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009895238+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009898748+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 24 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009902349+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009907109+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009911519+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009915609+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009918939+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009922329+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 25 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009928329+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009931909+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009935489+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009939429+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009942699+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009946019+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 26 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009949559+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.009953489+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00996244+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00996647+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00996987+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00997314+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 27 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00997664+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00998035+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00998418+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00998793+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00999131+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00999534+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 28 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.00999894+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01000268+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.0100087+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01001258+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01001605+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01002007+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 29 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01002393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010027531+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010031351+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010035521+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010038781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010042281+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 30 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010046091+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010054261+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010057881+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010061781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010064931+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010068261+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 31 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010072151+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010075981+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010079841+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010098802+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010103202+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010106382+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 32 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010109852+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010113242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010116952+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010120672+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010124112+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010127162+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 33 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010130662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010134442+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010138142+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010141972+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010145362+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010152262+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 34 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010156353+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010159913+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010163533+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010167403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010170533+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010173953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 35 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010177453+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010181033+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010184693+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010188583+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010191753+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010195033+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 36 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010198603+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010202393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010206123+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010210583+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010213503+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010216914+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 37 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010220134+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010223644+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010227154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010235154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010238354+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010241754+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 38 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010270284+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010275144+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010278714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010282825+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010285795+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010288935+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 39 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010292405+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010295915+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010299545+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010311835+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010315565+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010318975+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 40 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010322655+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010326225+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010329815+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010333745+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010337095+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010340515+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 41 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010344066+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010347706+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010355596+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010359046+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010362036+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010365076+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 42 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010367956+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010370906+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010375366+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010378386+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010381006+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010383666+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 43 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010386676+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010389786+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010392636+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010395746+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010398466+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010401166+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 44 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010404226+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010407186+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010410117+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010413237+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010416157+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010418897+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 45 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010423157+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010430107+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010433197+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010451207+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010454217+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010456967+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 46 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010459877+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010463967+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010467067+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010470597+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010473418+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010476108+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 47 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010478998+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010481958+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010484978+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010488258+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010490888+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010493628+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 48 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010496588+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010499498+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010502498+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010505768+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010508558+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010514148+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 49 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010517178+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010520238+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010523478+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010526818+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010529718+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010532508+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 50 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010535639+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010538749+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010541939+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010546689+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010549869+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010552989+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 51 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010556299+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010560079+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010563479+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010567189+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010570299+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010573079+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 52 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010576319+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010579599+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010583099+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010590519+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010594029+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010596989+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 53 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01059986+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01060433+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01060789+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01061136+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01061393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01061667+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 54 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01061984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.0106227+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01062566+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01062876+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01063131+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01063428+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 55 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01063719+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01063997+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01064278+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01064582+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01064836+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01065114+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 56 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01065437+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.01065724+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010671481+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010675671+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010678431+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010691171+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 57 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010694531+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010697641+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010700781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010704001+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010706611+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010709421+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 58 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010712331+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010715351+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010718431+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010721691+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010724371+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010727362+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 59 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010730592+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010733662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010736712+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010740162+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010742922+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010745772+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 60 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010752222+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010755402+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010758542+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010761822+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010764592+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010767992+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 61 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010771062+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010774282+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010779382+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010782942+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010785722+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010788573+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 62 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010791893+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010794943+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010798083+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010801683+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010804293+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010807283+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 63 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010810443+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010813553+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010816793+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010820233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010823063+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010829493+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 64 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010832683+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010835953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010839223+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010842463+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010845333+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010848423+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 65 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010851663+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010854964+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010858364+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010861714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010864914+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010867984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 66 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010870944+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010874114+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010877464+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010880504+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010883144+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010886194+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 67 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010889154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010892234+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010895524+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010902154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010904984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010907984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 68 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010911064+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010914074+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010917365+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
|
||||
{"Time":"2026-10-25T15:35:21.010920505+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:35:21.013209581+01:00","Action":"output","Package":"example.com/deeptimeout","Output":"FAIL\texample.com/deeptimeout\t2.021s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:35:21.013258851+01:00","Action":"fail","Package":"example.com/deeptimeout","Elapsed":2.022}
|
||||
@@ -0,0 +1,10 @@
|
||||
{"Time":"2026-10-25T15:34:33.768742837+01:00","Action":"start","Package":"example.com/exiting"}
|
||||
{"Time":"2026-10-25T15:34:33.773822287+01:00","Action":"run","Package":"example.com/exiting","Test":"TestFine"}
|
||||
{"Time":"2026-10-25T15:34:33.773896608+01:00","Action":"output","Package":"example.com/exiting","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.773940319+01:00","Action":"output","Package":"example.com/exiting","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.773945359+01:00","Action":"pass","Package":"example.com/exiting","Test":"TestFine","Elapsed":0}
|
||||
{"Time":"2026-10-25T15:34:33.773952529+01:00","Action":"run","Package":"example.com/exiting","Test":"TestExits"}
|
||||
{"Time":"2026-10-25T15:34:33.773956439+01:00","Action":"output","Package":"example.com/exiting","Test":"TestExits","Output":"=== RUN TestExits\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.773959829+01:00","Action":"output","Package":"example.com/exiting","Test":"TestExits","Output":" p_test.go:11: closing the store\n"}
|
||||
{"Time":"2026-10-25T15:34:33.775158538+01:00","Action":"output","Package":"example.com/exiting","Output":"FAIL\texample.com/exiting\t0.006s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.775186509+01:00","Action":"fail","Package":"example.com/exiting","Elapsed":0.006}
|
||||
@@ -0,0 +1,7 @@
|
||||
{"Time":"2026-10-25T15:34:34.328258934+01:00","Action":"start","Package":"example.com/killed"}
|
||||
{"Time":"2026-10-25T15:34:34.331465435+01:00","Action":"run","Package":"example.com/killed","Test":"TestKilled"}
|
||||
{"Time":"2026-10-25T15:34:34.331699248+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":"=== RUN TestKilled\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:34.331853111+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":" p_test.go:10: the kernel is about to kill this\n"}
|
||||
{"Time":"2026-10-25T15:34:34.334563213+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":"signal: killed\n"}
|
||||
{"Time":"2026-10-25T15:34:34.334611114+01:00","Action":"output","Package":"example.com/killed","Output":"FAIL\texample.com/killed\t0.006s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:34.334627134+01:00","Action":"fail","Package":"example.com/killed","Elapsed":0.006}
|
||||
@@ -0,0 +1,13 @@
|
||||
{"Action":"start","Package":"x"}
|
||||
{"Action":"run","Package":"x","Test":"TestThree"}
|
||||
{"Action":"output","Package":"x","Test":"TestThree","Output":"=== RUN TestThree\n","OutputType":"frame"}
|
||||
{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:4: judged against /var/lib/mesh-build-go-work/id/beside/mesh-catalog as cloned\n"}
|
||||
{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:5: 3 subscription(s) derive to a subject no emitter owns:\n","OutputType":"error"}
|
||||
{"Action":"output","Package":"x","Test":"TestThree","Output":" a.b.c\n","OutputType":"error-continue"}
|
||||
{"Action":"output","Package":"x","Test":"TestThree","Output":" d.e.f\n","OutputType":"error-continue"}
|
||||
{"Action":"output","Package":"x","Test":"TestThree","Output":" g.h.i\n","OutputType":"error-continue"}
|
||||
{"Action":"output","Package":"x","Test":"TestThree","Output":"--- FAIL: TestThree (0.00s)\n","OutputType":"frame"}
|
||||
{"Action":"fail","Package":"x","Test":"TestThree","Elapsed":0}
|
||||
{"Action":"output","Package":"x","Output":"FAIL\n","OutputType":"frame"}
|
||||
{"Action":"output","Package":"x","Output":"FAIL\tx\t0.002s\n","OutputType":"frame"}
|
||||
{"Action":"fail","Package":"x","Elapsed":0.002}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"Time":"2026-10-25T15:34:35.274953775+01:00","Action":"start","Package":"example.com/mainexit"}
|
||||
{"Time":"2026-10-25T15:34:35.278129565+01:00","Action":"run","Package":"example.com/mainexit","Test":"TestFine"}
|
||||
{"Time":"2026-10-25T15:34:35.278191516+01:00","Action":"output","Package":"example.com/mainexit","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:35.278341979+01:00","Action":"output","Package":"example.com/mainexit","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:35.278351739+01:00","Action":"pass","Package":"example.com/mainexit","Test":"TestFine","Elapsed":0}
|
||||
{"Time":"2026-10-25T15:34:35.278359039+01:00","Action":"output","Package":"example.com/mainexit","Output":"PASS\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:35.279516487+01:00","Action":"output","Package":"example.com/mainexit","Output":"FAIL\texample.com/mainexit\t0.004s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:35.279539298+01:00","Action":"fail","Package":"example.com/mainexit","Elapsed":0.005}
|
||||
@@ -0,0 +1,27 @@
|
||||
{"Time":"2026-10-25T15:34:33.031091436+01:00","Action":"start","Package":"example.com/panicking"}
|
||||
{"Time":"2026-10-25T15:34:33.036353279+01:00","Action":"run","Package":"example.com/panicking","Test":"TestBefore"}
|
||||
{"Time":"2026-10-25T15:34:33.036441781+01:00","Action":"output","Package":"example.com/panicking","Test":"TestBefore","Output":"=== RUN TestBefore\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.036568163+01:00","Action":"output","Package":"example.com/panicking","Test":"TestBefore","Output":"--- PASS: TestBefore (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.036609863+01:00","Action":"pass","Package":"example.com/panicking","Test":"TestBefore","Elapsed":0}
|
||||
{"Time":"2026-10-25T15:34:33.036641304+01:00","Action":"run","Package":"example.com/panicking","Test":"TestPanics"}
|
||||
{"Time":"2026-10-25T15:34:33.036645914+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"=== RUN TestPanics\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.036698995+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":" p_test.go:8: about to look up the seat\n"}
|
||||
{"Time":"2026-10-25T15:34:33.036773996+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"--- FAIL: TestPanics (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.03897418+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"panic: assignment to entry in nil map [recovered, repanicked]\n"}
|
||||
{"Time":"2026-10-25T15:34:33.040332172+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:34:33.040414493+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"goroutine 8 [running]:\n"}
|
||||
{"Time":"2026-10-25T15:34:33.040521475+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner.func1.2({0x6b8120, 0x6eff80})\n"}
|
||||
{"Time":"2026-10-25T15:34:33.040601826+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2125 +0x232\n"}
|
||||
{"Time":"2026-10-25T15:34:33.040641947+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner.func1()\n"}
|
||||
{"Time":"2026-10-25T15:34:33.040720648+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2128 +0x329\n"}
|
||||
{"Time":"2026-10-25T15:34:33.04083286+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"panic({0x6b8120?, 0x6eff80?})\n"}
|
||||
{"Time":"2026-10-25T15:34:33.040912921+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/runtime/panic.go:859 +0x125\n"}
|
||||
{"Time":"2026-10-25T15:34:33.040981652+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"example.com/panicking.TestPanics(0x228226cd0488?)\n"}
|
||||
{"Time":"2026-10-25T15:34:33.041059903+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/tmp/fix-1901b/panicking/p_test.go:10 +0x53\n"}
|
||||
{"Time":"2026-10-25T15:34:33.041132704+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner(0x228226cd0488, 0x6d5bc8)\n"}
|
||||
{"Time":"2026-10-25T15:34:33.041215586+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
|
||||
{"Time":"2026-10-25T15:34:33.041221966+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"created by testing.(*T).Run in goroutine 1\n"}
|
||||
{"Time":"2026-10-25T15:34:33.041225556+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
|
||||
{"Time":"2026-10-25T15:34:33.041984088+01:00","Action":"fail","Package":"example.com/panicking","Test":"TestPanics","Elapsed":0}
|
||||
{"Time":"2026-10-25T15:34:33.041996638+01:00","Action":"output","Package":"example.com/panicking","Output":"FAIL\texample.com/panicking\t0.010s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:33.042044079+01:00","Action":"fail","Package":"example.com/panicking","Elapsed":0.011}
|
||||
@@ -0,0 +1,22 @@
|
||||
{"Time":"2026-10-26T19:40:29.574068887+01:00","Action":"start","Package":"example.com/passfirst/a"}
|
||||
{"Time":"2026-10-26T19:40:29.574347185+01:00","Action":"start","Package":"example.com/passfirst/b"}
|
||||
{"Time":"2026-10-26T19:40:29.576503518+01:00","Action":"run","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses"}
|
||||
{"Time":"2026-10-26T19:40:29.576557657+01:00","Action":"output","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Output":"=== RUN TestTheFirstPackagePasses\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.576589367+01:00","Action":"output","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Output":"--- PASS: TestTheFirstPackagePasses (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.576594637+01:00","Action":"pass","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Elapsed":0}
|
||||
{"Time":"2026-10-26T19:40:29.576602487+01:00","Action":"output","Package":"example.com/passfirst/a","Output":"PASS\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.576614477+01:00","Action":"run","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst"}
|
||||
{"Time":"2026-10-26T19:40:29.576626897+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Output":"=== RUN TestTheSecondPackagePassesFirst\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.576631187+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Output":"--- PASS: TestTheSecondPackagePassesFirst (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.576635567+01:00","Action":"pass","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Elapsed":0}
|
||||
{"Time":"2026-10-26T19:40:29.576638427+01:00","Action":"run","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails"}
|
||||
{"Time":"2026-10-26T19:40:29.576640647+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":"=== RUN TestTheSecondPackageFails\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.576643477+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":" b_test.go:8: judged against the seat as it stands\n"}
|
||||
{"Time":"2026-10-26T19:40:29.576646497+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":" b_test.go:9: the seat has 2 holders, want 1\n","OutputType":"error"}
|
||||
{"Time":"2026-10-26T19:40:29.576650087+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":"--- FAIL: TestTheSecondPackageFails (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.576652797+01:00","Action":"fail","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Elapsed":0}
|
||||
{"Time":"2026-10-26T19:40:29.576655177+01:00","Action":"output","Package":"example.com/passfirst/b","Output":"FAIL\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.577365541+01:00","Action":"output","Package":"example.com/passfirst/a","Output":"ok \texample.com/passfirst/a\t0.003s\n"}
|
||||
{"Time":"2026-10-26T19:40:29.577382561+01:00","Action":"pass","Package":"example.com/passfirst/a","Elapsed":0.003}
|
||||
{"Time":"2026-10-26T19:40:29.577380641+01:00","Action":"output","Package":"example.com/passfirst/b","Output":"FAIL\texample.com/passfirst/b\t0.003s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.5774131+01:00","Action":"fail","Package":"example.com/passfirst/b","Elapsed":0.003}
|
||||
@@ -0,0 +1,15 @@
|
||||
{"Time":"2026-10-25T15:34:34.919624192+01:00","Action":"start","Package":"example.com/passthenfail"}
|
||||
{"Time":"2026-10-25T15:34:34.922861224+01:00","Action":"run","Package":"example.com/passthenfail","Test":"TestFine"}
|
||||
{"Time":"2026-10-25T15:34:34.922932965+01:00","Action":"output","Package":"example.com/passthenfail","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:34.922969205+01:00","Action":"output","Package":"example.com/passthenfail","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:34.922979956+01:00","Action":"pass","Package":"example.com/passthenfail","Test":"TestFine","Elapsed":0}
|
||||
{"Time":"2026-10-25T15:34:34.922989416+01:00","Action":"output","Package":"example.com/passthenfail","Output":"PASS\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:34.925022528+01:00","Action":"output","Package":"example.com/passthenfail","Output":"panic: the bus closed after the tests: nats: connection closed\n"}
|
||||
{"Time":"2026-10-25T15:34:34.925043388+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:34:34.925046848+01:00","Action":"output","Package":"example.com/passthenfail","Output":"goroutine 20 [running]:\n"}
|
||||
{"Time":"2026-10-25T15:34:34.925051338+01:00","Action":"output","Package":"example.com/passthenfail","Output":"example.com/passthenfail.TestMain.func1()\n"}
|
||||
{"Time":"2026-10-25T15:34:34.925055878+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\t/tmp/fix-1901b/passthenfail/p_test.go:13 +0x25\n"}
|
||||
{"Time":"2026-10-25T15:34:34.925061428+01:00","Action":"output","Package":"example.com/passthenfail","Output":"created by example.com/passthenfail.TestMain in goroutine 1\n"}
|
||||
{"Time":"2026-10-25T15:34:34.925065028+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\t/tmp/fix-1901b/passthenfail/p_test.go:13 +0x25\n"}
|
||||
{"Time":"2026-10-25T15:34:34.925390203+01:00","Action":"output","Package":"example.com/passthenfail","Output":"FAIL\texample.com/passthenfail\t0.005s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:34.925402904+01:00","Action":"fail","Package":"example.com/passthenfail","Elapsed":0.006}
|
||||
@@ -0,0 +1,53 @@
|
||||
{"Time":"2026-10-25T15:50:42.908557309+01:00","Action":"start","Package":"example.com/race"}
|
||||
{"Time":"2026-10-25T15:50:42.924298679+01:00","Action":"run","Package":"example.com/race","Test":"TestBefore"}
|
||||
{"Time":"2026-10-25T15:50:42.92438195+01:00","Action":"output","Package":"example.com/race","Test":"TestBefore","Output":"=== RUN TestBefore\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:42.924648842+01:00","Action":"output","Package":"example.com/race","Test":"TestBefore","Output":"--- PASS: TestBefore (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:42.924685133+01:00","Action":"pass","Package":"example.com/race","Test":"TestBefore","Elapsed":0}
|
||||
{"Time":"2026-10-25T15:50:42.924728573+01:00","Action":"run","Package":"example.com/race","Test":"TestRacesOnTheSeat"}
|
||||
{"Time":"2026-10-25T15:50:42.924733573+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"=== RUN TestRacesOnTheSeat\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:42.92637298+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"==================\n"}
|
||||
{"Time":"2026-10-25T15:50:42.92641567+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"WARNING: DATA RACE\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926444061+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Write at 0x000000833468 by goroutine 10:\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926462621+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat.func1()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926467121+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:13 +0x24\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926483061+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926509741+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Previous write at 0x000000833468 by goroutine 9:\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926530142+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926534952+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:14 +0x30\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926550462+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926554612+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926571142+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run.gowrap1()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926575812+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0x38\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926592202+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926607792+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Goroutine 10 (running) created at:\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926624292+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926628923+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:13 +0x24\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926643853+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926648103+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926674843+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run.gowrap1()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926680673+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0x38\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926695433+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926711063+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Goroutine 9 (running) created at:\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926726614+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926730814+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0xb12\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926746484+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.runTests.func1()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926750764+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2744 +0x84\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926765354+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926769844+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926791794+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.runTests()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926796704+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2742 +0x9e9\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926811544+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*M).Run()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926816694+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2602 +0xf44\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926831695+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" main.main()\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926835935+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" _testmain.go:50 +0x164\n"}
|
||||
{"Time":"2026-10-25T15:50:42.926850905+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"==================\n"}
|
||||
{"Time":"2026-10-25T15:50:42.935320241+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.go:1867: race detected during execution of test\n","OutputType":"error"}
|
||||
{"Time":"2026-10-25T15:50:42.935520113+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"--- FAIL: TestRacesOnTheSeat (0.01s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:42.935592973+01:00","Action":"fail","Package":"example.com/race","Test":"TestRacesOnTheSeat","Elapsed":0.01}
|
||||
{"Time":"2026-10-25T15:50:42.935722225+01:00","Action":"run","Package":"example.com/race","Test":"TestAfter"}
|
||||
{"Time":"2026-10-25T15:50:42.935728845+01:00","Action":"output","Package":"example.com/race","Test":"TestAfter","Output":"=== RUN TestAfter\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:42.935995928+01:00","Action":"output","Package":"example.com/race","Test":"TestAfter","Output":"--- PASS: TestAfter (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:42.936006328+01:00","Action":"pass","Package":"example.com/race","Test":"TestAfter","Elapsed":0}
|
||||
{"Time":"2026-10-25T15:50:42.936009978+01:00","Action":"output","Package":"example.com/race","Output":"FAIL\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:42.937730745+01:00","Action":"output","Package":"example.com/race","Output":"FAIL\texample.com/race\t0.028s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:42.937802066+01:00","Action":"fail","Package":"example.com/race","Elapsed":0.029}
|
||||
@@ -0,0 +1,27 @@
|
||||
{"Time":"2026-10-25T15:50:43.551701745+01:00","Action":"start","Package":"example.com/racemain"}
|
||||
{"Time":"2026-10-25T15:50:43.570709708+01:00","Action":"run","Package":"example.com/racemain","Test":"TestFine"}
|
||||
{"Time":"2026-10-25T15:50:43.570806059+01:00","Action":"output","Package":"example.com/racemain","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:43.57085525+01:00","Action":"output","Package":"example.com/racemain","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:43.57086149+01:00","Action":"pass","Package":"example.com/racemain","Test":"TestFine","Elapsed":0}
|
||||
{"Time":"2026-10-25T15:50:43.57086974+01:00","Action":"output","Package":"example.com/racemain","Output":"PASS\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:43.572155623+01:00","Action":"output","Package":"example.com/racemain","Output":"==================\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572168083+01:00","Action":"output","Package":"example.com/racemain","Output":"WARNING: DATA RACE\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572171933+01:00","Action":"output","Package":"example.com/racemain","Output":"Write at 0x000000836428 by goroutine 9:\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572176703+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain.func1()\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572180053+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:15 +0x24\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572184923+01:00","Action":"output","Package":"example.com/racemain","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572189613+01:00","Action":"output","Package":"example.com/racemain","Output":"Previous write at 0x000000836428 by main goroutine:\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572192993+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain()\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572198573+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:16 +0x44\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572202433+01:00","Action":"output","Package":"example.com/racemain","Output":" main.main()\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572205623+01:00","Action":"output","Package":"example.com/racemain","Output":" _testmain.go:48 +0x171\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572208443+01:00","Action":"output","Package":"example.com/racemain","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572211573+01:00","Action":"output","Package":"example.com/racemain","Output":"Goroutine 9 (running) created at:\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572214803+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain()\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572218043+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:15 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572225414+01:00","Action":"output","Package":"example.com/racemain","Output":" main.main()\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572228654+01:00","Action":"output","Package":"example.com/racemain","Output":" _testmain.go:48 +0x171\n"}
|
||||
{"Time":"2026-10-25T15:50:43.572231664+01:00","Action":"output","Package":"example.com/racemain","Output":"==================\n"}
|
||||
{"Time":"2026-10-25T15:50:44.581262249+01:00","Action":"output","Package":"example.com/racemain","Output":"Found 1 data race(s)\n"}
|
||||
{"Time":"2026-10-25T15:50:44.584693204+01:00","Action":"output","Package":"example.com/racemain","Output":"FAIL\texample.com/racemain\t1.031s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:50:44.584737425+01:00","Action":"fail","Package":"example.com/racemain","Elapsed":1.033}
|
||||
@@ -0,0 +1,30 @@
|
||||
{"Time":"2026-10-26T20:15:37.791161045+01:00","Action":"start","Package":"example.com/subpanic"}
|
||||
{"Time":"2026-10-26T20:15:37.793205666+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable"}
|
||||
{"Time":"2026-10-26T20:15:37.793257158+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"=== RUN TestTable\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T20:15:37.793288149+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable/passes"}
|
||||
{"Time":"2026-10-26T20:15:37.793290669+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/passes","Output":"=== RUN TestTable/passes\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T20:15:37.793298009+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/passes","Output":"--- PASS: TestTable/passes (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T20:15:37.793311679+01:00","Action":"pass","Package":"example.com/subpanic","Test":"TestTable/passes","Elapsed":0}
|
||||
{"Time":"2026-10-26T20:15:37.79331977+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable/panics"}
|
||||
{"Time":"2026-10-26T20:15:37.79332377+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/panics","Output":"=== RUN TestTable/panics\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T20:15:37.79332773+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/panics","Output":"--- FAIL: TestTable/panics (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T20:15:37.79333111+01:00","Action":"fail","Package":"example.com/subpanic","Test":"TestTable/panics","Elapsed":0}
|
||||
{"Time":"2026-10-26T20:15:37.79333383+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"--- FAIL: TestTable (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T20:15:37.795322529+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"panic: assignment to entry in nil map [recovered, repanicked]\n"}
|
||||
{"Time":"2026-10-26T20:15:37.795331489+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\n"}
|
||||
{"Time":"2026-10-26T20:15:37.795340579+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"goroutine 9 [running]:\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79534387+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner.func1.2({0x6b7010, 0x6eef80})\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79534657+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2125 +0x232\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79534907+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner.func1()\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79535172+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2128 +0x329\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79535625+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"panic({0x6b7010?, 0x6eef80?})\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79535879+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/runtime/panic.go:859 +0x125\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79536129+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"example.com/subpanic.TestTable.func2(0x3b39436666c8?)\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79536452+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/tmp/1901k/go/subpanic/p_test.go:7 +0x28\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79536782+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner(0x3b39436666c8, 0x6d4b58)\n"}
|
||||
{"Time":"2026-10-26T20:15:37.7953704+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
|
||||
{"Time":"2026-10-26T20:15:37.79537289+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"created by testing.(*T).Run in goroutine 7\n"}
|
||||
{"Time":"2026-10-26T20:15:37.795381681+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
|
||||
{"Time":"2026-10-26T20:15:37.795731441+01:00","Action":"fail","Package":"example.com/subpanic","Test":"TestTable","Elapsed":0}
|
||||
{"Time":"2026-10-26T20:15:37.795759332+01:00","Action":"output","Package":"example.com/subpanic","Output":"FAIL\texample.com/subpanic\t0.004s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T20:15:37.795783503+01:00","Action":"fail","Package":"example.com/subpanic","Elapsed":0.005}
|
||||
@@ -0,0 +1,17 @@
|
||||
{"Time":"2026-10-26T19:40:29.768101399+01:00","Action":"start","Package":"example.com/subtests"}
|
||||
{"Time":"2026-10-26T19:40:29.770109733+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable"}
|
||||
{"Time":"2026-10-26T19:40:29.770152272+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable","Output":"=== RUN TestTheTable\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.770189372+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable/passes"}
|
||||
{"Time":"2026-10-26T19:40:29.770191882+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/passes","Output":"=== RUN TestTheTable/passes\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.770197442+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/passes","Output":"--- PASS: TestTheTable/passes (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.770204352+01:00","Action":"pass","Package":"example.com/subtests","Test":"TestTheTable/passes","Elapsed":0}
|
||||
{"Time":"2026-10-26T19:40:29.770209622+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable/fails"}
|
||||
{"Time":"2026-10-26T19:40:29.770211832+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":"=== RUN TestTheTable/fails\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.770214722+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":" s_test.go:7: got 3 machines, want 4\n","OutputType":"error"}
|
||||
{"Time":"2026-10-26T19:40:29.770218032+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":"--- FAIL: TestTheTable/fails (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.770220732+01:00","Action":"fail","Package":"example.com/subtests","Test":"TestTheTable/fails","Elapsed":0}
|
||||
{"Time":"2026-10-26T19:40:29.770223522+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable","Output":"--- FAIL: TestTheTable (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.770226292+01:00","Action":"fail","Package":"example.com/subtests","Test":"TestTheTable","Elapsed":0}
|
||||
{"Time":"2026-10-26T19:40:29.770228582+01:00","Action":"output","Package":"example.com/subtests","Output":"FAIL\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.770561979+01:00","Action":"output","Package":"example.com/subtests","Output":"FAIL\texample.com/subtests\t0.002s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-26T19:40:29.770581629+01:00","Action":"fail","Package":"example.com/subtests","Elapsed":0.002}
|
||||
@@ -0,0 +1,43 @@
|
||||
{"Time":"2026-10-25T15:34:36.049580618+01:00","Action":"start","Package":"example.com/timeout"}
|
||||
{"Time":"2026-10-25T15:34:36.053250986+01:00","Action":"run","Package":"example.com/timeout","Test":"TestQuick"}
|
||||
{"Time":"2026-10-25T15:34:36.053354018+01:00","Action":"output","Package":"example.com/timeout","Test":"TestQuick","Output":"=== RUN TestQuick\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:36.053459789+01:00","Action":"output","Package":"example.com/timeout","Test":"TestQuick","Output":"--- PASS: TestQuick (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:36.05348499+01:00","Action":"pass","Package":"example.com/timeout","Test":"TestQuick","Elapsed":0}
|
||||
{"Time":"2026-10-25T15:34:36.0535114+01:00","Action":"run","Package":"example.com/timeout","Test":"TestWaitsForever"}
|
||||
{"Time":"2026-10-25T15:34:36.05351549+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"=== RUN TestWaitsForever\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:36.053567451+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":" p_test.go:11: waiting for the bus\n"}
|
||||
{"Time":"2026-10-25T15:34:38.064693307+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"panic: test timed out after 2s\n"}
|
||||
{"Time":"2026-10-25T15:34:38.06487122+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\trunning tests:\n"}
|
||||
{"Time":"2026-10-25T15:34:38.06491684+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t\tTestWaitsForever (2s)\n"}
|
||||
{"Time":"2026-10-25T15:34:38.065034382+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:34:38.065108573+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 33 [running]:\n"}
|
||||
{"Time":"2026-10-25T15:34:38.065154754+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*M).startAlarm.func1()\n"}
|
||||
{"Time":"2026-10-25T15:34:38.065386208+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2961 +0x34a\n"}
|
||||
{"Time":"2026-10-25T15:34:38.065431878+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"created by time.goFunc\n"}
|
||||
{"Time":"2026-10-25T15:34:38.0655151+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/time/sleep.go:182 +0x2d\n"}
|
||||
{"Time":"2026-10-25T15:34:38.06553471+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:34:38.065847805+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 1 [chan receive]:\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066038298+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*T).Run(0x3a0209bc8008, {0x5580aa?, 0x3a0209a9caa0?}, 0x6d5b10)\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066047488+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2268 +0x4f2\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066051368+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.runTests.func1(0x3a0209bc8008)\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066056128+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2744 +0x37\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066059478+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.tRunner(0x3a0209bc8008, 0x3a0209a9cbc8)\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066064958+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066069599+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.runTests({0x559017, 0x13}, {0x559017, 0x13}, 0x3a0209b8e0d8, {0x6f19f0, 0x2, 0x2}, {0xc2afa2bf832a3f56, 0x773908b5, ...})\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066083309+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2742 +0x510\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066086879+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*M).Run(0x3a0209b8c1e0)\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066090269+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2602 +0x6af\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066093429+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"main.main()\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066097569+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t_testmain.go:48 +0x9b\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066100639+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066103769+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 20 [sleep]:\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066106969+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"time.Sleep(0xdf8475800)\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066110279+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066113639+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"example.com/timeout.TestWaitsForever(0x3a0209bc8488?)\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066117109+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/tmp/fix-1901b/timeout/p_test.go:12 +0x48\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066120239+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.tRunner(0x3a0209bc8488, 0x6d5b10)\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066123509+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066126839+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"created by testing.(*T).Run in goroutine 1\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066129989+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
|
||||
{"Time":"2026-10-25T15:34:38.066986453+01:00","Action":"output","Package":"example.com/timeout","Output":"FAIL\texample.com/timeout\t2.017s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-25T15:34:38.067002113+01:00","Action":"fail","Package":"example.com/timeout","Elapsed":2.017}
|
||||
@@ -0,0 +1,2 @@
|
||||
npm notice run test
|
||||
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
|
||||
@@ -0,0 +1,41 @@
|
||||
TAP version 13
|
||||
# Subtest: passes before the crash
|
||||
ok 1 - passes before the crash
|
||||
---
|
||||
duration_ms: 0.746694
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: throws after it returned
|
||||
ok 2 - throws after it returned
|
||||
---
|
||||
duration_ms: 0.763364
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: waits after the throw
|
||||
ok 3 - waits after the throw
|
||||
---
|
||||
duration_ms: 49.953893
|
||||
type: 'test'
|
||||
...
|
||||
# Error: Test "throws after it returned" at test/b.test.ts:2:1 generated asynchronous activity after the test ended. This activity created the error "Error: the bus closed under the test" and would have caused the test to fail, but instead triggered an uncaughtException event.
|
||||
# Subtest: test/b.test.ts
|
||||
not ok 2 - test/b.test.ts
|
||||
---
|
||||
duration_ms: 155.864774
|
||||
type: 'test'
|
||||
location: '/tmp/1901k/node/crash/test/b.test.ts:1:1'
|
||||
failureType: 'testCodeFailure'
|
||||
exitCode: 1
|
||||
signal: ~
|
||||
error: 'test failed'
|
||||
code: 'ERR_TEST_FAILURE'
|
||||
...
|
||||
1..4
|
||||
# tests 4
|
||||
# suites 0
|
||||
# pass 3
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 168.479173
|
||||
@@ -0,0 +1,2 @@
|
||||
npm notice run test
|
||||
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
|
||||
@@ -0,0 +1,42 @@
|
||||
TAP version 13
|
||||
# retrying after Error: the store was not ready yet
|
||||
# Subtest: passes and logs
|
||||
ok 1 - passes and logs
|
||||
---
|
||||
duration_ms: 1.184595
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: throws after it returned
|
||||
ok 2 - throws after it returned
|
||||
---
|
||||
duration_ms: 0.748862
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: waits after the throw
|
||||
ok 3 - waits after the throw
|
||||
---
|
||||
duration_ms: 51.019875
|
||||
type: 'test'
|
||||
...
|
||||
# Error: Test "throws after it returned" at test/b.test.ts:2:1 generated asynchronous activity after the test ended. This activity created the error "Error: the bus closed under the test" and would have caused the test to fail, but instead triggered an uncaughtException event.
|
||||
# Subtest: test/b.test.ts
|
||||
not ok 2 - test/b.test.ts
|
||||
---
|
||||
duration_ms: 172.91715
|
||||
type: 'test'
|
||||
location: '/tmp/1901k/node/logcrash/test/b.test.ts:1:1'
|
||||
failureType: 'testCodeFailure'
|
||||
exitCode: 1
|
||||
signal: ~
|
||||
error: 'test failed'
|
||||
code: 'ERR_TEST_FAILURE'
|
||||
...
|
||||
1..4
|
||||
# tests 4
|
||||
# suites 0
|
||||
# pass 3
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 185.744334
|
||||
@@ -0,0 +1,2 @@
|
||||
npm notice run test
|
||||
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
|
||||
@@ -0,0 +1,61 @@
|
||||
TAP version 13
|
||||
# Subtest: the seat
|
||||
# Subtest: passes inside
|
||||
ok 1 - passes inside
|
||||
---
|
||||
duration_ms: 0.577396
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: fails inside
|
||||
not ok 2 - fails inside
|
||||
---
|
||||
duration_ms: 1.621198
|
||||
type: 'test'
|
||||
location: '/tmp/1901k/node/suite/test/a.test.ts:5:3'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly deep-equal:
|
||||
[32m+ actual[39m [31m- expected[39m
|
||||
|
||||
[39m {
|
||||
[32m+[39m holders: 2
|
||||
[31m-[39m holders: 1
|
||||
[39m }
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
holders: 1
|
||||
actual:
|
||||
holders: 2
|
||||
operator: 'deepStrictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///tmp/1901k/node/suite/test/a.test.ts:5:37)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Suite.processPendingSubtests (node:internal/test_runner/test:974:18)
|
||||
Test.postRun (node:internal/test_runner/test:1542:19)
|
||||
Test.run (node:internal/test_runner/test:1467:12)
|
||||
async Promise.all (index 0)
|
||||
async Suite.run (node:internal/test_runner/test:1905:7)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..2
|
||||
not ok 1 - the seat
|
||||
---
|
||||
duration_ms: 3.295664
|
||||
type: 'suite'
|
||||
location: '/tmp/1901k/node/suite/test/a.test.ts:3:1'
|
||||
failureType: 'subtestsFailed'
|
||||
error: '1 subtest failed'
|
||||
code: 'ERR_TEST_FAILURE'
|
||||
...
|
||||
1..1
|
||||
# tests 2
|
||||
# suites 1
|
||||
# pass 1
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 115.948112
|
||||
@@ -0,0 +1,2 @@
|
||||
npm notice run test
|
||||
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
|
||||
@@ -0,0 +1,47 @@
|
||||
TAP version 13
|
||||
# Subtest: passes in the first file
|
||||
ok 1 - passes in the first file
|
||||
---
|
||||
duration_ms: 0.637465
|
||||
type: 'test'
|
||||
...
|
||||
# node:internal/modules/run_main:111
|
||||
# triggerUncaughtException(
|
||||
# ^
|
||||
# file:///tmp/1901k/node/syntax/test/b.test.ts:2
|
||||
# test('never loads', () => {
|
||||
# SyntaxError [ERR_INVALID_TYPESCRIPT_SYNTAX]: Expected ',', got '<eof>'
|
||||
# [90m at parseTypeScript (node:internal/modules/typescript:61:36)[39m
|
||||
# [90m at processTypeScriptCode (node:internal/modules/typescript:129:37)[39m
|
||||
# [90m at Object.stripTypeScriptModuleTypes (node:internal/modules/typescript:172:22)[39m
|
||||
# [90m at stripTypeScriptModuleTypes (node:internal/modules/esm/translators:35:49)[39m
|
||||
# [90m at ModuleLoader.<anonymous> (node:internal/modules/esm/translators:688:29)[39m
|
||||
# [90m at \#translate (node:internal/modules/esm/loader:441:20)[39m
|
||||
# [90m at afterLoad (node:internal/modules/esm/loader:509:29)[39m
|
||||
# [90m at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:514:12)[39m
|
||||
# [90m at \#getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:567:36)[39m
|
||||
# [90m at afterResolve (node:internal/modules/esm/loader:614:52)[39m {
|
||||
# code: [32m'ERR_INVALID_TYPESCRIPT_SYNTAX'[39m
|
||||
# }
|
||||
# Node.js v26.12.1
|
||||
# Subtest: test/b.test.ts
|
||||
not ok 2 - test/b.test.ts
|
||||
---
|
||||
duration_ms: 87.629815
|
||||
type: 'test'
|
||||
location: '/tmp/1901k/node/syntax/test/b.test.ts:1:1'
|
||||
failureType: 'testCodeFailure'
|
||||
exitCode: 1
|
||||
signal: ~
|
||||
error: 'test failed'
|
||||
code: 'ERR_TEST_FAILURE'
|
||||
...
|
||||
1..2
|
||||
# tests 2
|
||||
# suites 0
|
||||
# pass 1
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 106.551435
|
||||
@@ -0,0 +1,2 @@
|
||||
npm notice run test
|
||||
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
|
||||
@@ -0,0 +1,26 @@
|
||||
TAP version 13
|
||||
# Subtest: passes before the wait
|
||||
ok 1 - passes before the wait
|
||||
---
|
||||
duration_ms: 0.683015
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: waits for an answer that never comes
|
||||
not ok 2 - waits for an answer that never comes
|
||||
---
|
||||
duration_ms: 201.103775
|
||||
type: 'test'
|
||||
location: '/tmp/1901k/node/timeout/test/a.test.ts:3:1'
|
||||
failureType: 'testTimeoutFailure'
|
||||
error: 'test timed out after 200ms'
|
||||
code: 'ERR_TEST_FAILURE'
|
||||
...
|
||||
1..2
|
||||
# tests 2
|
||||
# suites 0
|
||||
# pass 1
|
||||
# fail 0
|
||||
# cancelled 1
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 308.920061
|
||||
@@ -0,0 +1,2 @@
|
||||
npm notice run test
|
||||
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
|
||||
@@ -0,0 +1,49 @@
|
||||
TAP version 13
|
||||
# Subtest: passes in the first file
|
||||
ok 1 - passes in the first file
|
||||
---
|
||||
duration_ms: 0.740124
|
||||
type: 'test'
|
||||
...
|
||||
# judged against the seat as it stands
|
||||
# Subtest: passes in the second file first
|
||||
ok 2 - passes in the second file first
|
||||
---
|
||||
duration_ms: 0.916463
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: fails in the second file
|
||||
not ok 3 - fails in the second file
|
||||
---
|
||||
duration_ms: 1.719236
|
||||
type: 'test'
|
||||
location: '/tmp/1901k/node/two-files/test/b.test.ts:4:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
one plus one is not three
|
||||
|
||||
2 !== 3
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 3
|
||||
actual: 2
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///tmp/1901k/node/two-files/test/b.test.ts:6:10)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Test.processPendingSubtests (node:internal/test_runner/test:974:18)
|
||||
Test.postRun (node:internal/test_runner/test:1542:19)
|
||||
Test.run (node:internal/test_runner/test:1467:12)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..3
|
||||
# tests 3
|
||||
# suites 0
|
||||
# pass 2
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 135.931121
|
||||
@@ -0,0 +1 @@
|
||||
seat.ts(2,14): error TS2322: Type 'number' is not assignable to type 'string'.
|
||||
@@ -0,0 +1,201 @@
|
||||
// Package conformance is the kit every build module's tests run (novox/hq issue 1901, its thorough fix): real output of
|
||||
// the test runners, captured with the build nodes' own toolchains (captured/README.md), and what a build module must say
|
||||
// of each failure in it under the failure contract (buildstep.Failure). A build module's test calls Run with its own parse
|
||||
// path, the code that turns a runner's output into the failures of its test result; the kit fails the test when a
|
||||
// failure is not named, its cause is missing or wrong, or a passing line stands in its place.
|
||||
//
|
||||
// A catalogue check (mesh-catalog checks/failurekit) refuses a build module with a test step whose tests do not call Run,
|
||||
// and any build module whose tests do not call LeftRunning, the kit's case of steps a previous process left running
|
||||
// (leftrunning.go, issue 1890).
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// The runners the kit holds output of.
|
||||
const (
|
||||
// GoTest is `go test -json -count=1 ./...`: Output.Stdout is its output (go1.27.3).
|
||||
GoTest = "go test -json"
|
||||
// NodeTest is a package's `npm test` running `node --test --experimental-strip-types` with
|
||||
// NODE_OPTIONS=buildstep.NodeTestOptions(<file>): Output.Report is the TAP file, Output.Stdout the script's own output
|
||||
// and stderr, Output.Dir the package's directory as it was captured (node v26.12.1, npm 12.2.1).
|
||||
NodeTest = "node --test"
|
||||
// TypeCheck is tsc's type-check (`tsc --noEmit`, typescript 5.9.3): Output.Stdout is what it wrote; its failure
|
||||
// names the test "type-check".
|
||||
TypeCheck = "tsc"
|
||||
)
|
||||
|
||||
// Output is one captured run of a runner, as a build module's parse path takes it.
|
||||
type Output struct {
|
||||
Stdout, Stderr string
|
||||
// Report is what a runner wrote to a report file of its own (node --test's TAP), "" for the others.
|
||||
Report string
|
||||
// Dir is the directory the run was captured in: a node test's file is said relative to it.
|
||||
Dir string
|
||||
}
|
||||
|
||||
// Parse is a build module's own parse path: the failures its test result would carry for one run that failed.
|
||||
type Parse func(Output) []buildstep.Failure
|
||||
|
||||
// Case is one captured run and what its failure must say.
|
||||
type Case struct {
|
||||
Name string
|
||||
Runner string
|
||||
// Test is the failing test the failure names (for a package that stopped, the tests that were running; "" for none).
|
||||
Test string
|
||||
// File, when set, is where the failing test is.
|
||||
File string
|
||||
// Cause holds what the failure's cause must contain.
|
||||
Cause []string
|
||||
// Passing are the names of tests that passed in the run, and what they logged: no failure's words may hold them.
|
||||
Passing []string
|
||||
}
|
||||
|
||||
//go:embed captured
|
||||
var captured embed.FS
|
||||
|
||||
// Cases are the kit's cases of a runner.
|
||||
func Cases(runner string) []Case {
|
||||
var out []Case
|
||||
for _, c := range cases {
|
||||
if c.Runner == runner {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var cases = []Case{
|
||||
{Name: "passfirst", Runner: GoTest, Test: "TestTheSecondPackageFails", Cause: []string{"the seat has 2 holders, want 1"},
|
||||
Passing: []string{"TestTheFirstPackagePasses", "TestTheSecondPackagePassesFirst"}},
|
||||
{Name: "subtests", Runner: GoTest, Test: "TestTheTable/fails", Cause: []string{"got 3 machines, want 4"}, Passing: []string{"TestTheTable/passes"}},
|
||||
{Name: "logthenfatal", Runner: GoTest, Test: "TestThree", Cause: []string{"3 subscription(s) derive to a subject no emitter owns:"}},
|
||||
{Name: "panicking", Runner: GoTest, Test: "TestPanics", Cause: []string{"panic: assignment to entry in nil map"}},
|
||||
{Name: "timeout", Runner: GoTest, Test: "TestWaitsForever", Cause: []string{"panic: test timed out after 2s", "TestWaitsForever"}},
|
||||
{Name: "deeptimeout", Runner: GoTest, Test: "TestHoldsTheSeat, TestWaitsForTheBus", Cause: []string{"panic: test timed out after 2s", "TestHoldsTheSeat", "TestWaitsForTheBus"}},
|
||||
{Name: "buildfail", Runner: GoTest, Cause: []string{`cannot use "seat" (untyped string constant) as int value`}},
|
||||
{Name: "exiting", Runner: GoTest, Test: "TestExits", Cause: []string{"exited", "TestExits"}},
|
||||
{Name: "killed", Runner: GoTest, Test: "TestKilled", Cause: []string{"signal: killed"}},
|
||||
{Name: "passthenfail", Runner: GoTest, Cause: []string{"panic: the bus closed after the tests: nats: connection closed"}},
|
||||
{Name: "mainexit", Runner: GoTest, Cause: []string{"passed", "exited"}},
|
||||
{Name: "race", Runner: GoTest, Test: "TestRacesOnTheSeat", Cause: []string{"DATA RACE", "Write at", "example.com/race.TestRacesOnTheSeat.func1()", "p_test.go:13", "created at example.com/race.TestRacesOnTheSeat()"}},
|
||||
{Name: "subpanic", Runner: GoTest, Test: "TestTable/panics", Cause: []string{"panic: assignment to entry in nil map"}, Passing: []string{"TestTable/passes"}},
|
||||
{Name: "racemain", Runner: GoTest, Cause: []string{"DATA RACE", "Write at", "example.com/racemain.TestMain.func1()", "p_test.go:15", "created at example.com/racemain.TestMain()"}},
|
||||
|
||||
{Name: "two-files", Runner: NodeTest, Test: "fails in the second file", File: "test/b.test.ts:4:1", Cause: []string{"one plus one is not three"},
|
||||
Passing: []string{"passes in the first file", "passes in the second file first"}},
|
||||
{Name: "suite", Runner: NodeTest, Test: "fails inside", File: "test/a.test.ts:5:3", Cause: []string{"Expected values to be strictly deep-equal:"},
|
||||
Passing: []string{"passes inside"}},
|
||||
{Name: "crash", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"the bus closed under the test"},
|
||||
Passing: []string{"passes before the crash"}},
|
||||
{Name: "logcrash", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"the bus closed under the test"},
|
||||
Passing: []string{"passes and logs", "retrying after Error: the store was not ready yet"}},
|
||||
{Name: "timeout", Runner: NodeTest, Test: "waits for an answer that never comes", File: "test/a.test.ts:3:1", Cause: []string{"test timed out after 200ms"},
|
||||
Passing: []string{"passes before the wait"}},
|
||||
{Name: "syntax", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"SyntaxError", "Expected ',', got '<eof>'"},
|
||||
Passing: []string{"passes in the first file"}},
|
||||
|
||||
{Name: "typeerror", Runner: TypeCheck, Test: "type-check", Cause: []string{"seat.ts(2,14): error TS2322: Type 'number' is not assignable to type 'string'."}},
|
||||
}
|
||||
|
||||
// Load is the captured output of a case.
|
||||
func Load(c Case) (Output, error) {
|
||||
switch c.Runner {
|
||||
case GoTest:
|
||||
b, err := captured.ReadFile("captured/go/" + c.Name + ".jsonl")
|
||||
return Output{Stdout: string(b)}, err
|
||||
case NodeTest:
|
||||
report, err := captured.ReadFile("captured/node/" + c.Name + ".tap")
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
console, err := captured.ReadFile("captured/node/" + c.Name + ".console")
|
||||
return Output{Report: string(report), Stdout: string(console), Dir: "/tmp/1901k/node/" + c.Name}, err
|
||||
default:
|
||||
b, err := captured.ReadFile("captured/tsc/" + c.Name + ".console")
|
||||
return Output{Stdout: string(b), Dir: "/tmp/1901k/tsc/" + c.Name}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Run runs every case of runner through parse, each as a subtest, and fails a case whose failure does not keep the
|
||||
// contract: exactly one failure, naming the failing test (and its file, where the runner says it), with a cause that
|
||||
// holds what the case says and is no passing line, an output that starts with the cause, stays within its bound and
|
||||
// holds no passing line and no passing test's name.
|
||||
func Run(t *testing.T, runner string, parse Parse) {
|
||||
t.Helper()
|
||||
cs := Cases(runner)
|
||||
if len(cs) == 0 {
|
||||
t.Fatalf("the kit holds no case of the runner %q", runner)
|
||||
}
|
||||
for _, c := range cs {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
out, err := Load(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, problem := range Judge(c, parse(out)) {
|
||||
t.Error(problem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Judge says what is wrong with the failures a parse path made of a case; nothing when they keep the contract.
|
||||
func Judge(c Case, failed []buildstep.Failure) []string {
|
||||
var problems []string
|
||||
say := func(s string) { problems = append(problems, c.Runner+" "+c.Name+": "+s) }
|
||||
if len(failed) != 1 {
|
||||
say(fmt.Sprintf("want one failure, got %d: %s", len(failed), describe(failed)))
|
||||
if len(failed) == 0 {
|
||||
return problems
|
||||
}
|
||||
}
|
||||
f := failed[0]
|
||||
if f.Test != c.Test {
|
||||
say("the failure names the test " + quote(f.Test) + ", want " + quote(c.Test))
|
||||
}
|
||||
if c.File != "" && f.File != c.File {
|
||||
say("the failure's file is " + quote(f.File) + ", want " + quote(c.File))
|
||||
}
|
||||
if why := buildstep.Hidden(f); why != "" {
|
||||
say("the failure hides its cause: " + why)
|
||||
}
|
||||
for _, want := range c.Cause {
|
||||
if !strings.Contains(f.Cause, want) {
|
||||
say("the cause " + quote(f.Cause) + " does not hold " + quote(want))
|
||||
}
|
||||
}
|
||||
if first := strings.SplitN(f.Output, "\n", 2)[0]; strings.TrimSpace(first) != strings.TrimSpace(f.Cause) {
|
||||
say("the output starts with " + quote(first) + ", not with the cause")
|
||||
}
|
||||
if len(f.Output) > buildstep.OutputBytes+buildstep.LineBytes+64 {
|
||||
say(fmt.Sprintf("the output holds %d bytes, over its bound", len(f.Output)))
|
||||
}
|
||||
for _, l := range strings.Split(f.Output, "\n") {
|
||||
if buildstep.PassingLine(l) {
|
||||
say("the output keeps the passing line " + quote(l))
|
||||
}
|
||||
}
|
||||
for _, p := range c.Passing {
|
||||
if strings.Contains(f.Output, p+"\n") || strings.HasSuffix(f.Output, p) || strings.Contains(f.Cause, p) || strings.Contains(f.Output, "- "+p) {
|
||||
say("the failure's words hold the passing test " + quote(p))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func describe(failed []buildstep.Failure) string {
|
||||
var s []string
|
||||
for _, f := range failed {
|
||||
s = append(s, quote(f.Package+" "+f.Test)+" ("+quote(f.Cause)+")")
|
||||
}
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
|
||||
func quote(s string) string { return fmt.Sprintf("%q", s) }
|
||||
@@ -0,0 +1,167 @@
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// The failure contract (issue 1901): the SDK's own parsers keep it on every captured case of the kit (1901/AC6); the
|
||||
// kit refuses what the build modules sent before it; the contract's own rules; and the Go reader's older guarantees.
|
||||
func TestTheFailureContract(t *testing.T) {
|
||||
t.Run("the SDK parsers keep it", func(t *testing.T) {
|
||||
Run(t, GoTest, func(o Output) []buildstep.Failure { return buildstep.ReadGoTest(o.Stdout, ".").Failed })
|
||||
Run(t, NodeTest, func(o Output) []buildstep.Failure { return buildstep.ReadTAP(".", "test", o.Dir, o.Report, o.Stdout) })
|
||||
Run(t, TypeCheck, func(o Output) []buildstep.Failure {
|
||||
return []buildstep.Failure{buildstep.ConsoleFailure(".", "type-check", o.Stdout+o.Stderr)}
|
||||
})
|
||||
})
|
||||
|
||||
// 1890/AC7: the SDK's start sweep keeps the left-running case, and the kit refuses a sweep that ends nothing or ends
|
||||
// everything.
|
||||
t.Run("the left-running case", func(t *testing.T) {
|
||||
LeftRunning(t, func(ctx context.Context, scan buildstep.Scan, node string, before time.Time, logf func(string, ...any)) {
|
||||
buildstep.EndStaleAtStart(ctx, scan, node, before, logf)
|
||||
})
|
||||
if p := JudgeLeftRunning(func(context.Context, buildstep.Scan, string, time.Time, func(string, ...any)) {}); len(p) != 11 {
|
||||
t.Errorf("a sweep that ends nothing: %d problems, want 11 (three steps not ended and unsaid, one owner unnamed, two asks not deleted and unsaid):\n%s", len(p), strings.Join(p, "\n"))
|
||||
}
|
||||
wrongNode := func(ctx context.Context, scan buildstep.Scan, _ string, before time.Time, logf func(string, ...any)) {
|
||||
buildstep.EndStaleAtStart(ctx, scan, "kitother", before, logf)
|
||||
}
|
||||
if p := JudgeLeftRunning(wrongNode); len(p) == 0 {
|
||||
t.Error("a sweep of another node's steps is not refused")
|
||||
}
|
||||
})
|
||||
|
||||
// node's output cut to its first fifteen lines with no cause, whose first line was a passing test of the first file
|
||||
// (mesh-build-node's lines() before the contract), and a Go failure keeping its first line.
|
||||
t.Run("the kit refuses a failure that hides its cause", func(t *testing.T) {
|
||||
firstLines := func(s string) string {
|
||||
var keep []string
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" && len(keep) < 15 {
|
||||
keep = append(keep, l)
|
||||
}
|
||||
}
|
||||
return strings.Join(keep, "\n")
|
||||
}
|
||||
for _, c := range []struct {
|
||||
runner, name string
|
||||
old func(Output) []buildstep.Failure
|
||||
}{
|
||||
{NodeTest, "two-files", func(o Output) []buildstep.Failure {
|
||||
return []buildstep.Failure{{Package: ".", Test: "test", Output: firstLines(o.Report)}}
|
||||
}},
|
||||
{GoTest, "passfirst", func(o Output) []buildstep.Failure {
|
||||
return []buildstep.Failure{{Package: "example.com/passfirst/b", Test: "TestTheSecondPackageFails", Cause: "ok \texample.com/passfirst/a", Output: "--- PASS: TestTheSecondPackagePassesFirst"}}
|
||||
}},
|
||||
} {
|
||||
for _, k := range Cases(c.runner) {
|
||||
if k.Name != c.name {
|
||||
continue
|
||||
}
|
||||
out, err := Load(k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := Judge(k, c.old(out)); len(problems) == 0 {
|
||||
t.Errorf("%s %s: the kit passed a failure that hides its cause", c.runner, c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// What a passing line is (a test's own line starting "ok " is none), when a failure hides its cause, and that a long
|
||||
// failure keeps its last lines within the bound and starts with a real line for an older checker, which reads only
|
||||
// the first line of the output cut to 120 characters (1901/AC1, AC3).
|
||||
t.Run("its rules", func(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
line string
|
||||
passing bool
|
||||
}{
|
||||
{"✔ the same merge has the same id (8.0ms)", true}, {"ok 1 - passes", true}, {" ok 3 - inside", true},
|
||||
{"ok \texample.com/a\t0.01s", true}, {"PASS", true}, {"--- PASS: TestA (0.00s)", true},
|
||||
{" --- PASS: TestA/sub (0.00s)", true}, {"✖ fails (1ms)", false}, {"not ok 2 - fails", false},
|
||||
{"okay, the seat moved", false}, {"ok then: the store refused the write", false}, {"ok the seat", false}, {"FAIL", false},
|
||||
} {
|
||||
if buildstep.PassingLine(c.line) != c.passing {
|
||||
t.Errorf("PassingLine(%q) = %v, want %v", c.line, !c.passing, c.passing)
|
||||
}
|
||||
}
|
||||
if buildstep.Hidden(buildstep.Failure{Cause: "the seat has 2 holders"}) != "" || buildstep.Hidden(buildstep.Failure{}) == "" ||
|
||||
buildstep.Hidden(buildstep.Failure{Cause: "✔ passes (1ms)"}) == "" {
|
||||
t.Error("Hidden does not tell a real cause from none and from a passing line")
|
||||
}
|
||||
var lines []string
|
||||
for i := 0; i < 400; i++ {
|
||||
lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40))
|
||||
}
|
||||
lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)")
|
||||
oldFirst := func(out string) string { // the older checker's rule (judge.go firstLine before issue 1901)
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
return l[:min(len(l), 120)]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, cause := range []string{"the real cause", ""} {
|
||||
out := buildstep.Clip(lines, cause)
|
||||
if len(out) > buildstep.OutputBytes+buildstep.LineBytes {
|
||||
t.Errorf("1901/AC1: kept %d bytes, bound is %d", len(out), buildstep.OutputBytes)
|
||||
}
|
||||
if !strings.HasSuffix(out, "the real cause\n--- FAIL: TestX (0.00s)") || !strings.Contains(out, "\n... (") {
|
||||
t.Errorf("1901/AC1: the last lines or the marker are lost: %q...%q", out[:40], out[len(out)-60:])
|
||||
}
|
||||
if got := oldFirst(out); strings.HasPrefix(got, "... (") || got == "" || (cause != "" && got != cause) {
|
||||
t.Errorf("1901/AC3: cause %q: an older checker would read %q", cause, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The Go reader (moved from mesh-build-go): an older checker, which knows no "cause" field, still reads a failure and
|
||||
// gets at least the lines it kept (1901/AC3); a test's indented log line saying "panic:" is not the cause, go's own at
|
||||
// column 0 is (1901/AC4); and each package counts its own skipped tests (issue 890).
|
||||
t.Run("the Go reader", func(t *testing.T) {
|
||||
read := func(name string) string {
|
||||
raw, err := captured.ReadFile("captured/go/" + name + ".jsonl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
b, _ := json.Marshal(buildstep.ReadGoTest(read("logthenfatal"), ".").Failed)
|
||||
var old []struct {
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &old); err != nil || len(old) != 1 || !strings.Contains(old[0].Output, "judged against") || !strings.Contains(old[0].Output, "3 subscription(s)") {
|
||||
t.Errorf("1901/AC3: an older checker reads %+v, %v", old, err)
|
||||
}
|
||||
lines := strings.SplitAfter(read("deeptimeout"), "\n")
|
||||
added := `{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" p_test.go:9: the child said:\n"}` + "\n" +
|
||||
`{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" panic: child retried and recovered\n"}` + "\n" +
|
||||
`{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" WARNING: DATA RACE\n"}` + "\n"
|
||||
if got := buildstep.ReadGoTest(strings.Join(lines[:10], "")+added+strings.Join(lines[10:], ""), ".").Failed; len(got) != 1 || !strings.HasPrefix(got[0].Cause, "panic: test timed out after 2s") {
|
||||
t.Errorf("1901/AC4: failures = %+v, want the timeout's panic as the cause", got)
|
||||
}
|
||||
res := buildstep.ReadGoTest(`{"Action":"run","Package":"m/a","Test":"TestA"}
|
||||
{"Action":"skip","Package":"m/a","Test":"TestA","Elapsed":0}
|
||||
{"Action":"run","Package":"m/b","Test":"TestB"}
|
||||
{"Action":"pass","Package":"m/b","Test":"TestB","Elapsed":0}
|
||||
{"Action":"pass","Package":"m/a","Elapsed":0}
|
||||
{"Action":"pass","Package":"m/b","Elapsed":0}
|
||||
`, ".")
|
||||
got := map[string]int{}
|
||||
for _, p := range res.Packages {
|
||||
got[p.Package] = p.Skipped
|
||||
}
|
||||
if res.Skipped != 1 || got["m/a"] != 1 || got["m/b"] != 0 {
|
||||
t.Errorf("skipped = %d, per package %v, want 1 with m/a 1 and m/b 0", res.Skipped, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// **A step left running by a previous process** (novox/hq issue 1890, note 21): when a delivery replaced mesh-build-go
|
||||
// on a node while a check ask ran, the new process's start sweep let the ask's key stand as running, and it held one
|
||||
// of the node's build slots until a hand end. Every build module runs this case (LeftRunning) through the start sweep
|
||||
// its main runs: the hand-started steps a previous process left are ended at once and loud, its asks' keys deleted (the
|
||||
// stream redelivers the ask, novox/hq ADR 0525 §6, and its asker keeps waiting for that), what this process runs kept. A
|
||||
// catalogue check (mesh-catalog checks/failurekit) refuses a build module whose tests do not call LeftRunning.
|
||||
|
||||
// Sweep is a build module's own start sweep, as its main runs it once at its start: it ends node's steps in scan that
|
||||
// no process of the module runs any more (buildstep.EndStaleAtStart, here with the runtime already serving), before
|
||||
// being the start of the process, saying each end with logf.
|
||||
type Sweep func(ctx context.Context, scan buildstep.Scan, node string, before time.Time, logf func(string, ...any))
|
||||
|
||||
// LeftNode is the node the case's steps are of.
|
||||
const LeftNode = "kitnode"
|
||||
|
||||
// leftCase is one step in the steps state before the sweep, and whether the sweep must end it.
|
||||
type leftCase struct {
|
||||
id, tool, owner, state string
|
||||
node string
|
||||
age time.Duration // started this long before the process began (negative: after it)
|
||||
ended bool
|
||||
deleted bool // a gone process's ask: its key is deleted, not ended
|
||||
}
|
||||
|
||||
// gone is a previous process's owner on the same node.
|
||||
var gone = "b00t-of-the-kit.4242.mvp0000"
|
||||
|
||||
func leftCases() []leftCase {
|
||||
return []leftCase{
|
||||
{id: "mvpwlkzi7e6ca8", tool: "check ask", owner: gone, age: 5 * time.Minute, deleted: true}, // note 21's ghost
|
||||
{id: "mvpwlkzi7e6ca9", tool: "delivery ask", owner: gone, age: -time.Second, deleted: true},
|
||||
{id: "gonehand1", tool: "build_x_test", owner: gone, age: time.Minute, ended: true},
|
||||
{id: "oldhand1", tool: "build_x_fetch", age: time.Hour, ended: true}, // no owner: an older build's
|
||||
{id: "mvpwlkzi7e6caa", tool: "check ask", age: time.Hour, ended: true}, // no owner, past the bound
|
||||
{id: "mvpwlkzi7e6cab", tool: "check ask", age: 5 * time.Minute}, // no owner, the stream's still
|
||||
{id: "mvpwlkzi7e6cac", tool: "check ask", owner: buildstep.Owner, age: time.Minute}, // this process's
|
||||
{id: "minehand1", tool: "build_x_test", owner: buildstep.Owner, age: -time.Second},
|
||||
{id: "donehand1", tool: "build_x_test", owner: gone, age: time.Hour, state: delivery.StepPassed},
|
||||
{id: "otherhand1", tool: "build_x_test", owner: gone, age: time.Hour, node: "kitother"},
|
||||
}
|
||||
}
|
||||
|
||||
// LeftRunning runs the case through sweep and fails the test for each step the sweep ended or kept wrongly, or ended
|
||||
// without saying it.
|
||||
func LeftRunning(t *testing.T, sweep Sweep) {
|
||||
t.Helper()
|
||||
for _, problem := range JudgeLeftRunning(sweep) {
|
||||
t.Error(problem)
|
||||
}
|
||||
}
|
||||
|
||||
// JudgeLeftRunning says what is wrong with what sweep did to the case; nothing when it keeps the rule.
|
||||
func JudgeLeftRunning(sweep Sweep) []string {
|
||||
st := &steps{vals: map[string][]byte{}, revs: map[string]uint64{}}
|
||||
before := time.Now()
|
||||
for _, c := range leftCases() {
|
||||
node, state := c.node, c.state
|
||||
if node == "" {
|
||||
node = LeftNode
|
||||
}
|
||||
if state == "" {
|
||||
state = delivery.StepRunning
|
||||
}
|
||||
key, _ := delivery.StepKey(node, c.id)
|
||||
raw, err := delivery.EncodeStep(delivery.Step{ID: c.id, Tool: c.tool, Node: node, State: state, Started: before.Add(-c.age).UTC(), Owner: c.owner})
|
||||
if err != nil {
|
||||
return []string{"the kit's step " + c.id + ": " + err.Error()}
|
||||
}
|
||||
st.set(key, raw)
|
||||
}
|
||||
served := make(chan struct{})
|
||||
close(served)
|
||||
was := buildstep.StaleServed
|
||||
buildstep.StaleServed = func() <-chan struct{} { return served }
|
||||
defer func() { buildstep.StaleServed = was }()
|
||||
var mu sync.Mutex
|
||||
var said []string
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
sweep(ctx, st.scan(), LeftNode, before, func(f string, a ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
said = append(said, fmt.Sprintf(f, a...))
|
||||
})
|
||||
mu.Lock()
|
||||
log := strings.Join(said, "\n")
|
||||
mu.Unlock()
|
||||
var problems []string
|
||||
for _, c := range leftCases() {
|
||||
node := c.node
|
||||
if node == "" {
|
||||
node = LeftNode
|
||||
}
|
||||
key, _ := delivery.StepKey(node, c.id)
|
||||
raw := st.get(key)
|
||||
if c.deleted {
|
||||
if raw != nil {
|
||||
problems = append(problems, fmt.Sprintf("left running: the ask %s of the gone owner %q is still held after the sweep (%s); "+
|
||||
"its key is deleted, never ended: the stream redelivers it (ADR 0525 §6)", key, c.owner, raw))
|
||||
}
|
||||
if !strings.Contains(log, key) || !strings.Contains(log, c.owner) {
|
||||
problems = append(problems, "left running: the delete of "+key+" is not said with its owner "+c.owner)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if raw == nil {
|
||||
problems = append(problems, "left running: the step "+key+" is gone after the sweep, want it kept or ended")
|
||||
continue
|
||||
}
|
||||
s, err := delivery.DecodeStep(raw)
|
||||
if err != nil {
|
||||
problems = append(problems, key+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
want := c.state
|
||||
if want == "" {
|
||||
want = delivery.StepRunning
|
||||
}
|
||||
if c.ended {
|
||||
want = delivery.StepFailed
|
||||
}
|
||||
if s.State != want {
|
||||
problems = append(problems, fmt.Sprintf("left running: the step %s (%s, owner %q, %s before the start) is %s after the sweep, want %s", key, c.tool, c.owner, c.age, s.State, want))
|
||||
}
|
||||
if c.ended && !strings.Contains(log, key) {
|
||||
problems = append(problems, "left running: the end of "+key+" is not said")
|
||||
}
|
||||
if c.ended && c.owner != "" && !strings.Contains(log, c.owner) {
|
||||
problems = append(problems, "left running: the end of "+key+" does not name its owner "+c.owner)
|
||||
}
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
// steps is a steps state with revisions, whose Update lands only at the revision read (stdio.ErrChanged otherwise).
|
||||
type steps struct {
|
||||
mu sync.Mutex
|
||||
vals map[string][]byte
|
||||
revs map[string]uint64
|
||||
}
|
||||
|
||||
func (s *steps) set(key string, raw []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.vals[key], s.revs[key] = raw, s.revs[key]+1
|
||||
}
|
||||
|
||||
func (s *steps) get(key string) []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.vals[key]
|
||||
}
|
||||
|
||||
func (s *steps) scan() buildstep.Scan {
|
||||
return buildstep.Scan{
|
||||
Keys: func() ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []string
|
||||
for k := range s.vals {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
Get: func(key string) (json.RawMessage, uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.vals[key], s.revs[key], nil
|
||||
},
|
||||
Delete: func(key string, revision uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.revs[key] != revision {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
delete(s.vals, key)
|
||||
s.revs[key]++
|
||||
return nil
|
||||
},
|
||||
Update: func(key string, value any, revision uint64) (uint64, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.revs[key] != revision {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
s.vals[key], s.revs[key] = raw, revision+1
|
||||
return revision + 1, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package buildstep
|
||||
|
||||
// The failure contract of a build step's test level (novox/hq issue 1901, its thorough fix): what a build module says of
|
||||
// each test that failed, the same for every language. Each build module used to parse its own test runner's output, and
|
||||
// a fix in one never reached the others: a failed Go test kept a t.Logf line, a failed node --test run kept a passing
|
||||
// '✔' line of its first file. The parsers that fill the contract live here (gotest.go, tap.go) and the conformance kit
|
||||
// (package conformance) holds every build module to them with captured runner output.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/sdkversion"
|
||||
)
|
||||
|
||||
// Failure is one test that failed, or a package or file that failed with no test to name (a build that failed, a test
|
||||
// program that panicked, a test file that did not load). The JSON names are the ones mesh-build-go and the checker have
|
||||
// always used; File is new and an older reader ignores it.
|
||||
type Failure struct {
|
||||
// Package is the Go package's import path, or the TypeScript package's directory in the workspace.
|
||||
Package string `json:"package"`
|
||||
// Test names the failing test, or the tests that were running when the program stopped; "" when none can be named.
|
||||
Test string `json:"test,omitempty"`
|
||||
// File is where the failing test is (a Go test's file and line is in its Cause; node names the test's file:line:col).
|
||||
File string `json:"file,omitempty"`
|
||||
// Cause is the one line that says why: the assertion's or the error's first line, a "panic:" line, a build's first
|
||||
// error, or words saying how the program stopped. **Never empty, and never a passing line** (PassingLine).
|
||||
Cause string `json:"cause,omitempty"`
|
||||
// Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads
|
||||
// only its first line.
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
|
||||
// assertion message and stays far under a bus message's or a verdict's bound.
|
||||
const (
|
||||
OutputBytes = 4096
|
||||
LineBytes = 1024
|
||||
)
|
||||
|
||||
// By names a build module and the SDK it was built with, for a result's "by": the checker names it when a failure hides
|
||||
// its cause ("the build module hid the failure (<by>)").
|
||||
func By(module string) string {
|
||||
return module + " with mesh-sdk " + sdkversion.Version()
|
||||
}
|
||||
|
||||
// PassingLine says a line is a test runner's word for a test or a package that passed: node's '✔', TAP's "ok N" (at any
|
||||
// indent), go test's "ok \t<pkg>", "PASS" and "--- PASS". A line of a test's own that starts "ok " is none of these. Such a line is never a failure's cause, nor part of a failure's output.
|
||||
func PassingLine(l string) bool {
|
||||
if strings.HasPrefix(l, "ok \t") { // go test's package line, at column 0
|
||||
return true
|
||||
}
|
||||
t := strings.TrimSpace(l)
|
||||
switch {
|
||||
case t == "PASS", strings.HasPrefix(t, "✔"), strings.HasPrefix(t, "--- PASS"):
|
||||
return true
|
||||
}
|
||||
return tapOk(t)
|
||||
}
|
||||
|
||||
// Hidden says how a failure hides why it failed, or "" when it does not: it has no cause, or its cause is a passing line.
|
||||
// The checker says it loudly (issue 1901): a build module that sends such a failure does not keep the contract.
|
||||
func Hidden(f Failure) string {
|
||||
c := strings.TrimSpace(f.Cause)
|
||||
switch {
|
||||
case c == "":
|
||||
return "it sent no cause"
|
||||
case PassingLine(c):
|
||||
return fmt.Sprintf("its cause is a passing line (%q)", CutLine(c))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Clip joins the last lines of a failure's output that fit OutputBytes, each cut to LineBytes, and says how many earlier
|
||||
// lines it left out. **Its first line is the cause**, put ahead when the kept lines do not start with it, or, with no
|
||||
// cause and lines left out, the first non-empty line, then the marker: an older checker reads only the first line.
|
||||
// Passing lines are dropped.
|
||||
func Clip(lines []string, cause string) string {
|
||||
own := make([]string, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
if !PassingLine(l) {
|
||||
own = append(own, l)
|
||||
}
|
||||
}
|
||||
lines = own
|
||||
size, from := 0, len(lines)
|
||||
for from > 0 {
|
||||
n := len(CutLine(lines[from-1])) + 1
|
||||
if size+n > OutputBytes && from < len(lines) {
|
||||
break
|
||||
}
|
||||
size += n
|
||||
from--
|
||||
}
|
||||
kept := make([]string, 0, len(lines)-from+2)
|
||||
head := strings.TrimSpace(cause)
|
||||
for i := 0; head == "" && i < from; i++ {
|
||||
head = strings.TrimSpace(lines[i])
|
||||
}
|
||||
if head != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != head) {
|
||||
kept = append(kept, CutLine(head))
|
||||
}
|
||||
if from > 0 {
|
||||
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
|
||||
}
|
||||
for _, l := range lines[from:] {
|
||||
kept = append(kept, CutLine(l))
|
||||
}
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
|
||||
// CutLine cuts a line to LineBytes, on a character boundary.
|
||||
func CutLine(l string) string {
|
||||
if len(l) <= LineBytes {
|
||||
return l
|
||||
}
|
||||
return strings.ToValidUTF8(l[:LineBytes], "") + "..."
|
||||
}
|
||||
|
||||
// ConsoleFailure is the failure of a command that is no test runner (go vet, gofmt, tsc, a build script): its cause is the
|
||||
// first line that names an error (tsc's "error TS", go vet's "file.go:12:3: ..."), else the first line that is not a
|
||||
// heading ("# <package>", npm's "> script"), and its output the command's lines without passing ones.
|
||||
func ConsoleFailure(pkg, test, out string) Failure {
|
||||
var lines []string
|
||||
for _, l := range strings.Split(strings.ReplaceAll(stripANSI(out), "\r\n", "\n"), "\n") {
|
||||
if t := strings.TrimSpace(l); t != "" && !PassingLine(t) {
|
||||
lines = append(lines, strings.TrimRight(l, " \t"))
|
||||
}
|
||||
}
|
||||
cause := ""
|
||||
for _, l := range lines {
|
||||
if t := strings.TrimSpace(l); strings.Contains(t, "error") || strings.Contains(t, "Error") || goPosition.MatchString(t) {
|
||||
cause = t
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, l := range lines {
|
||||
if t := strings.TrimSpace(l); cause == "" && !strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "> ") && !strings.HasPrefix(t, "npm ") {
|
||||
cause = t
|
||||
}
|
||||
}
|
||||
if cause == "" {
|
||||
cause = test + " failed and wrote nothing that says why"
|
||||
}
|
||||
cause = CutLine(cause)
|
||||
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package buildstep
|
||||
|
||||
// The reader of `go test -json` (moved whole from mesh-catalog's mesh-build-go, issue 1901's thorough fix): per package
|
||||
// what ran and for how long, and each failure under the contract (failure.go), its real cause too when the failure is no
|
||||
// test's assertion (a panic, a timeout, a build error, an exit, a kill, a race).
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GoPackage is one package's part of a Go test level.
|
||||
type GoPackage struct {
|
||||
Dir string `json:"dir"`
|
||||
Package string `json:"package"`
|
||||
Passed bool `json:"passed"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
Tests int `json:"tests"`
|
||||
// Skipped is how many of the package's tests skipped.
|
||||
Skipped int `json:"skipped,omitempty"`
|
||||
// NoTests is a package with no test files (at this level: a file with the other level's build tag is none).
|
||||
NoTests bool `json:"no_tests,omitempty"`
|
||||
}
|
||||
|
||||
// TestTime is one test and the seconds it took.
|
||||
type TestTime struct {
|
||||
Package string `json:"package"`
|
||||
Test string `json:"test"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
}
|
||||
|
||||
// GoTest is what one go.mod directory's `go test -json` said.
|
||||
type GoTest struct {
|
||||
Packages []GoPackage
|
||||
Tests int
|
||||
Skipped int
|
||||
Times []TestTime
|
||||
Failed []Failure
|
||||
// Elapsed is the slowest package's running time in seconds.
|
||||
Elapsed float64
|
||||
// Noise is the lines that were no event: what go said when it could not run the tests at all.
|
||||
Noise []string
|
||||
}
|
||||
|
||||
// ReadGoTest reads the output of `go test -json` run in the go.mod directory dir.
|
||||
func ReadGoTest(out, dir string) GoTest {
|
||||
r := &goReader{dir: dir, pkgs: map[string]*GoPackage{}, builds: map[string][]string{}, pkgOut: map[string][]string{},
|
||||
testOut: map[string][]string{}, causes: map[string]string{}, failedIn: map[string]bool{},
|
||||
running: map[string][]string{}, passed: map[string]bool{}, built: map[string]bool{}}
|
||||
sc := bufio.NewScanner(strings.NewReader(out))
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<26)
|
||||
for sc.Scan() {
|
||||
var ev goEvent
|
||||
if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
|
||||
if l := strings.TrimSpace(sc.Text()); l != "" {
|
||||
r.res.Noise = append(r.res.Noise, l)
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.event(ev)
|
||||
}
|
||||
r.finish()
|
||||
return r.res
|
||||
}
|
||||
|
||||
// goEvent is one line of `go test -json` (test2json, and the build events go test adds).
|
||||
type goEvent struct {
|
||||
Action string
|
||||
Package string
|
||||
Test string
|
||||
Output string
|
||||
OutputType string // "error" for a t.Error or t.Fatal message, "error-continue" for its further lines
|
||||
ImportPath string
|
||||
FailedBuild string
|
||||
Elapsed float64
|
||||
}
|
||||
|
||||
type goReader struct {
|
||||
res GoTest
|
||||
dir string
|
||||
pkgs map[string]*GoPackage
|
||||
order []string
|
||||
builds map[string][]string // build output by import path
|
||||
pkgOut map[string][]string
|
||||
testOut map[string][]string // by package + "\x00" + test
|
||||
causes map[string]string // the first error line of a test, by the same key
|
||||
failedIn map[string]bool // packages with a failed test
|
||||
// running is the tests that started and have not ended, by package, in the order they started: when a package
|
||||
// fails with no failed test, these were running when its test program stopped.
|
||||
running map[string][]string
|
||||
// passed says a package's test program printed "PASS" before it failed; built says it failed to build.
|
||||
passed, built map[string]bool
|
||||
}
|
||||
|
||||
func (r *goReader) pkg(name string) *GoPackage {
|
||||
p := r.pkgs[name]
|
||||
if p == nil {
|
||||
p = &GoPackage{Dir: r.dir, Package: name}
|
||||
r.pkgs[name] = p
|
||||
r.order = append(r.order, name)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func keepLine(lines []string, out string) []string {
|
||||
if t := strings.TrimRight(out, "\r\n"); t != "" && !strings.HasPrefix(t, "=== ") {
|
||||
lines = append(lines, t)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (r *goReader) event(ev goEvent) {
|
||||
switch {
|
||||
case ev.Package == "" && ev.ImportPath != "":
|
||||
if ev.Action == "build-output" {
|
||||
r.builds[ev.ImportPath] = keepLine(r.builds[ev.ImportPath], ev.Output)
|
||||
}
|
||||
case ev.Package == "":
|
||||
case ev.Test == "":
|
||||
p := r.pkg(ev.Package)
|
||||
switch ev.Action {
|
||||
case "output":
|
||||
// go test's own summary of a package is never a failure's words: a "PASS" before a "FAIL <pkg>" says only
|
||||
// that the tests passed and the program failed after them.
|
||||
switch l := strings.TrimRight(ev.Output, "\r\n"); {
|
||||
case l == "PASS":
|
||||
r.passed[ev.Package] = true
|
||||
case goSummaryLine(l):
|
||||
default:
|
||||
r.pkgOut[ev.Package] = keepLine(r.pkgOut[ev.Package], ev.Output)
|
||||
}
|
||||
case "pass":
|
||||
p.Passed, p.Elapsed = true, ev.Elapsed
|
||||
case "fail":
|
||||
p.Passed, p.Elapsed = false, ev.Elapsed
|
||||
if ev.FailedBuild != "" {
|
||||
r.built[ev.Package] = true
|
||||
r.pkgOut[ev.Package] = append(r.builds[ev.FailedBuild], r.pkgOut[ev.Package]...)
|
||||
}
|
||||
case "skip":
|
||||
p.Passed, p.NoTests = true, true
|
||||
}
|
||||
default:
|
||||
key := ev.Package + "\x00" + ev.Test
|
||||
switch ev.Action {
|
||||
case "output":
|
||||
r.testOut[key] = keepLine(r.testOut[key], ev.Output)
|
||||
if ev.OutputType == "error" && r.causes[key] == "" {
|
||||
r.causes[key] = goErrorWords(ev.Output)
|
||||
}
|
||||
case "run":
|
||||
r.running[ev.Package] = append(r.running[ev.Package], ev.Test)
|
||||
case "pass", "fail", "skip":
|
||||
r.running[ev.Package] = slices.DeleteFunc(r.running[ev.Package], func(t string) bool { return t == ev.Test })
|
||||
p := r.pkg(ev.Package)
|
||||
p.Tests++
|
||||
r.res.Tests++
|
||||
if ev.Action == "skip" {
|
||||
r.res.Skipped++
|
||||
p.Skipped++
|
||||
}
|
||||
r.res.Times = append(r.res.Times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
|
||||
if ev.Action == "fail" {
|
||||
r.failedIn[ev.Package] = true
|
||||
if r.subtestFailed(ev.Package, ev.Test) {
|
||||
return // a parent failed only by its subtest is that subtest's failure, said once
|
||||
}
|
||||
if r.subtestTakesTheCause(ev.Package, ev.Test) {
|
||||
return // a subtest's panic, which go files under its parent, is said on the subtest
|
||||
}
|
||||
r.res.Failed = append(r.res.Failed, r.testFailure(ev.Package, ev.Test))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// subtestFailed says a failure of test under pkg was already reported for one of its subtests (they end first), each with
|
||||
// a cause of its own, and the test reported no error and no runtime cause (a subtest's panic) of its own.
|
||||
func (r *goReader) subtestFailed(pkg, test string) bool {
|
||||
key := pkg + "\x00" + test
|
||||
if r.causes[key] != "" || goRuntimeCause(r.testOut[key]) != "" {
|
||||
return false
|
||||
}
|
||||
for _, f := range r.res.Failed {
|
||||
if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// noMessage is the cause of a test that failed and said nothing of why.
|
||||
const noMessage = "the test failed without an error message (t.Fail or t.FailNow)"
|
||||
|
||||
// subtestTakesTheCause gives a parent's runtime cause (a subtest's panic: go test -json files its lines under the
|
||||
// parent) to the subtests of that parent that failed with no message of their own, and says it did. A parent with an
|
||||
// error line of its own, or whose failed subtests each said why, is reported as itself.
|
||||
func (r *goReader) subtestTakesTheCause(pkg, test string) bool {
|
||||
key := pkg + "\x00" + test
|
||||
if r.causes[key] != "" {
|
||||
return false
|
||||
}
|
||||
cause := goRuntimeCause(r.testOut[key])
|
||||
if cause == "" {
|
||||
return false
|
||||
}
|
||||
took := false
|
||||
for i, f := range r.res.Failed {
|
||||
if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") && f.Cause == noMessage {
|
||||
r.res.Failed[i].Cause, r.res.Failed[i].Output = cause, Clip(r.testOut[key], cause)
|
||||
took = true
|
||||
}
|
||||
}
|
||||
return took
|
||||
}
|
||||
|
||||
// testFailure is a test that failed: its cause is its first error line, the race it ran into, the runtime's line, or
|
||||
// words saying it failed without a message.
|
||||
func (r *goReader) testFailure(pkg, test string) Failure {
|
||||
key := pkg + "\x00" + test
|
||||
cause := r.causes[key]
|
||||
if race := goRaceCause(r.testOut[key]); race != "" && (cause == "" || strings.HasPrefix(cause, "race detected")) {
|
||||
cause = race // the race itself, not testing's "race detected during execution of test"
|
||||
} else if cause == "" {
|
||||
cause = goRuntimeCause(r.testOut[key])
|
||||
}
|
||||
if cause == "" {
|
||||
cause = noMessage
|
||||
}
|
||||
return Failure{Package: pkg, Test: test, Output: Clip(r.testOut[key], cause), Cause: cause}
|
||||
}
|
||||
|
||||
// goSummaryLine says a line is go test's summary of a package, in go's exact forms only: "PASS" or "FAIL" alone,
|
||||
// "ok \t<pkg>\t<time>" and "FAIL\t<pkg>\t<time>" (a line of the package's own that starts "ok " is kept).
|
||||
func goSummaryLine(l string) bool {
|
||||
return l == "PASS" || l == "FAIL" || strings.HasPrefix(l, "ok \t") || strings.HasPrefix(l, "FAIL\t")
|
||||
}
|
||||
|
||||
// goRuntimeCause is the cause in a failure's output that is not a test's assertion: a race the race detector reported;
|
||||
// else the first "panic:" line, a timeout's with the tests it names as running; else the first "fatal error:" or
|
||||
// "signal:" line; "" when there is none. It reads every line, so a long goroutine dump cannot hide it. **It matches only
|
||||
// lines at column 0**, where go prints the runtime's: a test's indented log line holding the same words is not the cause.
|
||||
func goRuntimeCause(lines []string) string {
|
||||
if race := goRaceCause(lines); race != "" {
|
||||
return race
|
||||
}
|
||||
for _, prefix := range []string{"panic: ", "fatal error: ", "signal: "} {
|
||||
for i, l := range lines {
|
||||
l = strings.TrimRight(l, "\r\n")
|
||||
if !strings.HasPrefix(l, prefix) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(l, "panic: test timed out after") {
|
||||
var names []string
|
||||
for _, n := range lines[i+1:] {
|
||||
if !strings.HasPrefix(n, "\t\t") {
|
||||
if strings.TrimSpace(n) == "running tests:" {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
names = append(names, strings.TrimSpace(n))
|
||||
}
|
||||
if len(names) > 0 {
|
||||
l += "; running tests: " + strings.Join(names, ", ")
|
||||
}
|
||||
}
|
||||
return CutLine(l)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// goRaceCause is the first race the race detector reported in a failure's output, in one line: its access and the frame
|
||||
// that made it, and where the goroutine that made it was created. "" when the output holds no race.
|
||||
func goRaceCause(lines []string) string {
|
||||
at := -1
|
||||
for i, l := range lines {
|
||||
if strings.TrimRight(l, "\r\n") == "WARNING: DATA RACE" {
|
||||
at = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
return ""
|
||||
}
|
||||
frame := func(i int) string {
|
||||
var fn, file string
|
||||
for _, l := range lines[i+1:] {
|
||||
if l = strings.TrimSpace(l); l == "" {
|
||||
break
|
||||
}
|
||||
if fn == "" {
|
||||
fn = l
|
||||
continue
|
||||
}
|
||||
file = l
|
||||
if j := strings.LastIndexByte(file, '/'); j >= 0 {
|
||||
file = file[j+1:]
|
||||
}
|
||||
if j := strings.Index(file, " +0x"); j >= 0 {
|
||||
file = file[:j]
|
||||
}
|
||||
break
|
||||
}
|
||||
if file != "" {
|
||||
return fn + " (" + file + ")"
|
||||
}
|
||||
return fn
|
||||
}
|
||||
words := "WARNING: DATA RACE"
|
||||
for i := at + 1; i < len(lines); i++ {
|
||||
l := strings.TrimSpace(lines[i])
|
||||
if l == "" || strings.HasPrefix(l, "===") {
|
||||
continue
|
||||
}
|
||||
words += ": " + strings.TrimSuffix(l, ":")
|
||||
if f := frame(i); f != "" {
|
||||
words += " in " + f
|
||||
}
|
||||
break
|
||||
}
|
||||
for i := at + 1; i < len(lines); i++ {
|
||||
l := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(l, "==================") {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(l, "Goroutine ") && strings.HasSuffix(l, " created at:") {
|
||||
if f := frame(i); f != "" {
|
||||
words += "; its goroutine created at " + f
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return CutLine(words)
|
||||
}
|
||||
|
||||
// goBuildCause is the first error line of a failed build: the first line that is not go's "# <package>" heading.
|
||||
func goBuildCause(lines []string) string {
|
||||
for _, l := range lines {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "# ") {
|
||||
return CutLine(l)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// goTestNames names the tests that were running, at most three, and how many more.
|
||||
func goTestNames(tests []string) string {
|
||||
if len(tests) <= 3 {
|
||||
return strings.Join(tests, ", ")
|
||||
}
|
||||
return fmt.Sprintf("%s and %d more", strings.Join(tests[:3], ", "), len(tests)-3)
|
||||
}
|
||||
|
||||
// packageFailure is a package that failed with no failed test to name it: its test program did not build, panicked,
|
||||
// timed out, was killed or exited. It names the tests that were running, keeps their output with the package's, and
|
||||
// says the cause.
|
||||
func (r *goReader) packageFailure(name string) Failure {
|
||||
running := r.running[name]
|
||||
var lines []string
|
||||
for _, t := range running {
|
||||
lines = append(lines, r.testOut[name+"\x00"+t]...)
|
||||
}
|
||||
lines = append(lines, r.pkgOut[name]...)
|
||||
cause := goRuntimeCause(lines)
|
||||
switch {
|
||||
case cause != "":
|
||||
case r.built[name]:
|
||||
cause = goBuildCause(lines)
|
||||
case len(running) > 0:
|
||||
cause = "the test program exited while " + goTestNames(running) + " ran, with no panic: an os.Exit or a signal (go test -json reports no exit status)"
|
||||
case r.passed[name]:
|
||||
cause = "the tests passed, then the test program exited with a failure: an os.Exit or a panic in TestMain or after the tests (go test -json reports no exit status)"
|
||||
}
|
||||
if cause == "" {
|
||||
cause = "the package failed with no test to name and no panic, signal or build error in its output"
|
||||
}
|
||||
return Failure{Package: name, Test: goTestNames(running), Output: Clip(lines, cause), Cause: cause}
|
||||
}
|
||||
|
||||
var goFileAndLine = regexp.MustCompile(`^\S+\.go:\d+: `)
|
||||
|
||||
// goPosition matches a line that starts with a Go file's position, as go vet and the compiler write an error.
|
||||
var goPosition = regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: `)
|
||||
|
||||
// goErrorWords is the words of an error line of a test: trimmed, without the file and line go test put before them.
|
||||
func goErrorWords(out string) string {
|
||||
return strings.TrimSpace(goFileAndLine.ReplaceAllString(strings.TrimSpace(out), ""))
|
||||
}
|
||||
|
||||
// finish moves what was read into the result: the packages in order, a failure for each failed package that has no
|
||||
// failed test to name it.
|
||||
func (r *goReader) finish() {
|
||||
for _, name := range r.order {
|
||||
p := r.pkgs[name]
|
||||
if !p.Passed && !r.failedIn[name] {
|
||||
r.res.Failed = append(r.res.Failed, r.packageFailure(name))
|
||||
}
|
||||
if !p.NoTests && p.Elapsed > r.res.Elapsed {
|
||||
r.res.Elapsed = p.Elapsed
|
||||
}
|
||||
r.res.Packages = append(r.res.Packages, *p)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,14 @@
|
||||
package buildstep
|
||||
|
||||
import "strings"
|
||||
import "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
|
||||
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
|
||||
const LauncherSuffix = ".serve.mjs"
|
||||
const LauncherSuffix = catalogue.LauncherSuffix
|
||||
|
||||
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root: for TypeScript
|
||||
// every entrypoint `x.js` is started by `x.serve.mjs` beside it; an entrypoint that is not JavaScript and every other
|
||||
// language, whose build is already executable, get none. The one rule mesh-build-node, build-agent and the controller
|
||||
// share (R4e), so the name a build writes is the name a reader expects.
|
||||
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root
|
||||
// (catalogue.Launchers): the one rule mesh-build-node, build-agent, the controller and a node taking in a build share
|
||||
// (R4e, issue 1126), so the name a build writes is the name a reader expects.
|
||||
func Launchers(language string, entrypoints []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
if language != "typescript" {
|
||||
return out
|
||||
}
|
||||
for _, entry := range entrypoints {
|
||||
if strings.HasSuffix(entry, ".js") {
|
||||
out[entry] = strings.TrimSuffix(entry, ".js") + LauncherSuffix
|
||||
}
|
||||
}
|
||||
return out
|
||||
return catalogue.Launchers(language, entrypoints)
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) {
|
||||
waitFor(t, "a phase on the step key", func() bool {
|
||||
key, _ := delivery.AskStepKey("shanks", p.Running()[0].ID)
|
||||
s, _ := st.get(key)
|
||||
return s.State == delivery.StepRunning && s.Phase == "unit: tests of 3 packages" && !s.PhaseAt.IsZero()
|
||||
return s.State == delivery.StepRunning && s.Phase == "unit: tests of 3 packages" && !s.PhaseAt.IsZero() && s.Owner == Owner // 1890/AC5
|
||||
})
|
||||
release <- struct{}{}
|
||||
waitFor(t, "the third taken", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
|
||||
@@ -176,7 +176,7 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) {
|
||||
waitFor(t, "all acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
|
||||
for _, a := range asks {
|
||||
key, _ := delivery.AskStepKey("shanks", a.ID)
|
||||
if s, _ := st.get(key); s.State != delivery.StepPassed || s.Phase != "unit: tests of 3 packages" {
|
||||
if s, _ := st.get(key); s.State != delivery.StepPassed || s.Phase != "unit: tests of 3 packages" || s.Owner != Owner {
|
||||
t.Errorf("the end of %s = %+v, want passed in its last phase", a.ID, s)
|
||||
}
|
||||
}
|
||||
|
||||
+121
-28
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,40 +16,82 @@ import (
|
||||
|
||||
// **A step left running** (novox/hq issue 1890, note 5). A build module that stops while a step runs leaves its key in
|
||||
// state running for good: nothing else writes that key's end, and the stream's redelivery only rewrites it when the same
|
||||
// ask comes back. At its start a module ends the steps its node left running before the process began (EndStale), and
|
||||
// its step-end tool ends one by hand (EndOne).
|
||||
// ask comes back. At its start a module ends the steps its node left running by another process (EndStale: every step
|
||||
// names its owner, delivery.Step.Owner; note 21), and its step-end tool ends one by hand (EndOne). The conformance kit's
|
||||
// left-running case holds every build module to the start sweep (conformance.LeftRunning).
|
||||
|
||||
// RestartReason is why a step a previous process left running is ended.
|
||||
const RestartReason = "the build module restarted while it ran"
|
||||
|
||||
// Boot is when this process began. A step started at or after it belongs to this process, which may still run it, so
|
||||
// EndOne refuses it.
|
||||
// Boot is when this process began. A step with no owner (written by an older build) started at or after it is taken
|
||||
// for this process's, which may still run it, so EndOne refuses it.
|
||||
var Boot = time.Now()
|
||||
|
||||
// Owner is this process as a step's owner (delivery.Step.Owner): `<boot id>.<pid>.<start>`, the node's boot id
|
||||
// (/proc/sys/kernel/random/boot_id), the pid and Boot in milliseconds, base 36. The start keeps two processes apart
|
||||
// that a reboot or a pid namespace gave the same pid. A running step whose owner is another is a previous process's.
|
||||
var Owner = ownerOf(bootID, os.Getpid(), Boot)
|
||||
|
||||
// bootID is the node's boot id as Owner holds it; noBootID when it could not be read, which EndStaleAtStart says once.
|
||||
var bootID = readBootID()
|
||||
|
||||
const noBootID = "no-boot-id"
|
||||
|
||||
func ownerOf(bootID string, pid int, start time.Time) string {
|
||||
return bootID + "." + strconv.Itoa(pid) + "." + strconv.FormatInt(start.UnixMilli(), 36)
|
||||
}
|
||||
|
||||
// readBootID is the node's boot id, or noBootID where it cannot be read (the pid and the start then tell the processes
|
||||
// apart).
|
||||
func readBootID() string {
|
||||
b, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
|
||||
if id := strings.TrimSpace(string(b)); err == nil && id != "" {
|
||||
return id
|
||||
}
|
||||
return noBootID
|
||||
}
|
||||
|
||||
// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for
|
||||
// none), and a write that lands only while the key is still at the revision read (KeptState.Update), failing with
|
||||
// stdio.ErrChanged when another writer was first.
|
||||
// none), a write that lands only while the key is still at the revision read (KeptState.Update), and a delete of a key
|
||||
// still at the revision read; both fail with stdio.ErrChanged when another writer was first.
|
||||
type Scan struct {
|
||||
Keys func() ([]string, error)
|
||||
Get func(key string) (json.RawMessage, uint64, error)
|
||||
Update func(key string, value any, revision uint64) (uint64, error)
|
||||
Delete func(key string, revision uint64) error
|
||||
}
|
||||
|
||||
// ScanOf is the Scan of a state the process keeps.
|
||||
//
|
||||
// Its Delete reads the key again and deletes it only while it is still at the revision read: the runtime's
|
||||
// mesh/state.delete takes no revision yet, so a write landing between that read and the delete (a few milliseconds) is
|
||||
// deleted with it. For a step key that is a redelivered ask begun on this node in that moment: its running key leaves
|
||||
// the steps state until its run writes its end, which its asker still reads (a watch takes puts only).
|
||||
func ScanOf(s stdio.KeptState) Scan {
|
||||
return Scan{Keys: s.Keys, Update: s.Update, Get: func(key string) (json.RawMessage, uint64, error) {
|
||||
get := func(key string) (json.RawMessage, uint64, error) {
|
||||
e, err := s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return e.Value, e.Revision, nil
|
||||
}
|
||||
return Scan{Keys: s.Keys, Update: s.Update, Get: get, Delete: func(key string, revision uint64) error {
|
||||
_, rev, err := get(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rev != revision {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.Delete(key)
|
||||
}}
|
||||
}
|
||||
|
||||
// isAsk says a step is a pulled ask's: its key is <node>.<ask id> and its tool is "<kind> ask" (a hand-started step
|
||||
// has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was
|
||||
// running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key.
|
||||
// Only once the asker's bound has passed (delivery.StepBound) is the key nobody's, and then it is ended.
|
||||
// A key a gone process owns is deleted, never ended (EndStale); one with no owner is ended only once the asker's bound
|
||||
// has passed (delivery.StepBound), when it is nobody's.
|
||||
func isAsk(key string, s delivery.Step) bool {
|
||||
_, _, err := delivery.ParseAskStepKey(key)
|
||||
return err == nil && strings.HasSuffix(s.Tool, " ask")
|
||||
@@ -95,11 +139,23 @@ func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why
|
||||
return err
|
||||
}
|
||||
|
||||
// EndStale ends this node's steps that are running and started before `before` (the start of this process) as failed
|
||||
// with RestartReason, and answers their ids. Only keys of node are read; a value that is no step is left alone. A step
|
||||
// of a pulled ask is left to the stream's redelivery (isAsk) until its start is older than delivery.StepBound: its asker
|
||||
// stopped waiting then, whether the stream gave it to another node or gave it up, and it is ended as failed, "past the
|
||||
// step bound: its asker stopped waiting at <time>". A key written by another writer since it was read
|
||||
// EndStale ends this node's running steps that no process of this module runs any more, and answers their ids. Only keys
|
||||
// of node are read; a value that is no step is left alone.
|
||||
// - A hand-started step with an owner (delivery.Step.Owner) that is not this process (Owner) is ended failed with
|
||||
// RestartReason at once, whatever its age: its process is gone and nothing redelivers it, so nothing else would
|
||||
// write its end (issue 1890, note 21).
|
||||
// - A pulled ask's step with an owner that is not this process is deleted, not ended (novox/hq ADR 0525 §6): the
|
||||
// stream redelivers an ask whose node stopped (the puller neither ends nor acknowledges one at its stop), and its
|
||||
// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its
|
||||
// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running
|
||||
// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it
|
||||
// held one of shanks' two slots).
|
||||
// - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it.
|
||||
// - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before
|
||||
// `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its
|
||||
// start is older than delivery.StepBound, and then ended "past the step bound: its asker stopped waiting at <time>".
|
||||
//
|
||||
// Each end and delete is logged, naming the step, its owner and why. A key written by another writer since it was read
|
||||
// (a redelivered ask's start, a real end) is skipped.
|
||||
func EndStale(scan Scan, node string, before time.Time, logf func(string, ...any)) ([]string, error) {
|
||||
keys, err := scan.Keys()
|
||||
@@ -123,32 +179,59 @@ func EndStale(scan Scan, node string, before time.Time, logf func(string, ...any
|
||||
if err != nil || step.State != delivery.StepRunning {
|
||||
continue
|
||||
}
|
||||
why := RestartReason
|
||||
if isAsk(k, step) {
|
||||
if !step.Started.Add(delivery.StepBound).Before(now()) {
|
||||
continue
|
||||
}
|
||||
why = "past the step bound: its asker stopped waiting at " + step.Started.Add(delivery.StepBound).UTC().Format("2006-01-02T15:04:05Z")
|
||||
} else if !step.Started.Before(before) {
|
||||
why, said := staleWhy(k, step, before)
|
||||
if why == "" {
|
||||
continue
|
||||
}
|
||||
if err := endRunning(scan, k, step, rev, why); err != nil {
|
||||
done := "ended"
|
||||
if step.Owner != "" && isAsk(k, step) {
|
||||
done = "deleted, left to the stream's redelivery"
|
||||
if scan.Delete == nil {
|
||||
err = fmt.Errorf("the steps state takes no delete")
|
||||
} else {
|
||||
err = scan.Delete(k, rev)
|
||||
}
|
||||
} else {
|
||||
err = endRunning(scan, k, step, rev, why)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return ended, fmt.Errorf("buildstep: ending the step %s: %w", k, err)
|
||||
}
|
||||
if logf != nil {
|
||||
logf("the step %s (%s) was left running and is ended: %s", k, step.Tool, why)
|
||||
logf("the step %s (%s, started %s) was left running and is %s: %s; %s", k, step.Tool,
|
||||
step.Started.UTC().Format(time.RFC3339), done, why, said)
|
||||
}
|
||||
ended = append(ended, step.ID)
|
||||
}
|
||||
return ended, nil
|
||||
}
|
||||
|
||||
// staleWhy is why a running step is ended (or, for an ask with an owner, deleted) by EndStale and what the log says of
|
||||
// its owner, or "" to leave it.
|
||||
func staleWhy(key string, step delivery.Step, before time.Time) (why, said string) {
|
||||
switch {
|
||||
case step.Owner == Owner:
|
||||
return "", ""
|
||||
case step.Owner != "":
|
||||
return RestartReason, "its owner " + step.Owner + " is not this process " + Owner
|
||||
case isAsk(key, step):
|
||||
if !step.Started.Add(delivery.StepBound).Before(now()) {
|
||||
return "", ""
|
||||
}
|
||||
return "past the step bound: its asker stopped waiting at " + step.Started.Add(delivery.StepBound).UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"it names no owner (an older build wrote it)"
|
||||
case step.Started.Before(before):
|
||||
return RestartReason, "it names no owner (an older build wrote it) and started before this process"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// EndOne ends this node's running step id (or the key <node>.<id>) as failed with why. A step that is not running is
|
||||
// refused, and so is one started since this process began (Boot: it may still run, whether a pulled ask or a step a
|
||||
// tool started), one live reports as held, and one whose key changed since it was read.
|
||||
// refused, and so is one this process owns (Owner; for a step with no owner, one started since Boot: it may still run,
|
||||
// whether a pulled ask or a step a tool started), one live reports as held, and one whose key changed since it was read.
|
||||
func EndOne(scan Scan, node, id, why string, live func(id string) bool) (delivery.Step, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if n, rest, ok := strings.Cut(id, "."); ok {
|
||||
@@ -178,7 +261,7 @@ func EndOne(scan Scan, node, id, why string, live func(id string) bool) (deliver
|
||||
if step.State != delivery.StepRunning {
|
||||
return delivery.Step{}, fmt.Errorf("the step %s is %s, not running", key, step.State)
|
||||
}
|
||||
if !step.Started.Before(Boot) || (live != nil && live(id)) {
|
||||
if step.Owner == Owner || (step.Owner == "" && !step.Started.Before(Boot)) || (live != nil && live(id)) {
|
||||
return delivery.Step{}, fmt.Errorf("the step %s was started by this process and may run now; only a step a previous process left is ended", key)
|
||||
}
|
||||
if err := endRunning(scan, key, step, rev, why); err != nil {
|
||||
@@ -226,14 +309,24 @@ var (
|
||||
StaleRetry = 5 * time.Second
|
||||
)
|
||||
|
||||
// StaleServed is what EndStaleAtStart waits for before it reads the steps state: the runtime serving (stdio.Served).
|
||||
// The conformance kit's left-running case (conformance.LeftRunning) hands it a closed channel, to run a module's own
|
||||
// start sweep in its test.
|
||||
var StaleServed = stdio.Served
|
||||
|
||||
// EndStaleAtStart is what a build module runs once at its start, in a goroutine: it waits for the runtime to serve,
|
||||
// then ends this node's steps left running before `before` (EndStale), trying again when the state cannot be read yet.
|
||||
// A step this process starts is never one of them: it starts after `before`.
|
||||
// then ends this node's steps a previous process left running (EndStale), trying again when the state cannot be read
|
||||
// yet. A step names its owner, so `before` (the start of this process) matters only for a step with no owner, written
|
||||
// by an older build: such a step started before it is a previous process's. When the node's boot id cannot be read it
|
||||
// says so once: Owner then tells processes apart by their pid and start only.
|
||||
func EndStaleAtStart(ctx context.Context, scan Scan, node string, before time.Time, logf func(string, ...any)) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stdio.Served():
|
||||
case <-StaleServed():
|
||||
}
|
||||
if bootID == noBootID && logf != nil {
|
||||
logf("the boot id of %s cannot be read (/proc/sys/kernel/random/boot_id): its steps' owner is %s, told apart by pid and start only", node, Owner)
|
||||
}
|
||||
for attempt := 1; ; attempt++ {
|
||||
ended, err := EndStale(scan, node, before, logf)
|
||||
|
||||
+52
-22
@@ -2,6 +2,7 @@ package buildstep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -33,6 +34,15 @@ func scanOf(k *keys, race func(key string)) Scan {
|
||||
raw, err := delivery.EncodeStep(s[len(s)-1])
|
||||
return raw, uint64(len(s)), err
|
||||
},
|
||||
Delete: func(key string, revision uint64) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if uint64(len(k.puts[key])) != revision {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
delete(k.puts, key)
|
||||
return nil
|
||||
},
|
||||
Update: func(key string, value any, revision uint64) (uint64, error) {
|
||||
if race != nil {
|
||||
race(key)
|
||||
@@ -48,65 +58,85 @@ func scanOf(k *keys, race func(key string)) Scan {
|
||||
}
|
||||
}
|
||||
|
||||
// 1890: EndStale ends only this node's hand-started steps left running before the process began and the asks past the
|
||||
// step bound (a younger ask is the stream's to redeliver), and never a key written meanwhile; EndOne ends one by hand, never one this process started.
|
||||
// 1890 and 1890/AC6: EndStale ends this node's hand-started running steps of another owner at once, whatever their age,
|
||||
// and deletes its asks of another owner (the stream redelivers them, ADR 0525 §6); of
|
||||
// the steps with no owner (an older build's), the hand-started ones left running before the process began and the asks
|
||||
// past the step bound (a younger ask is the stream's to redeliver); never a step this process owns or a key written
|
||||
// meanwhile. EndOne ends one by hand, never one this process owns or started.
|
||||
func TestEndStaleAndEndOne(t *testing.T) {
|
||||
k := newKeys()
|
||||
boot := time.Now()
|
||||
oldBoot := Boot
|
||||
Boot = boot
|
||||
defer func() { Boot = oldBoot }()
|
||||
put := func(node, id, tool, state string, started time.Time) {
|
||||
gone := ownerOf("b00t", 4242, boot.Add(-time.Hour))
|
||||
put := func(node, id, tool, state string, started time.Time, owner string) {
|
||||
key, _ := delivery.StepKey(node, id)
|
||||
_, _ = k.put(key, json.RawMessage(mustStep(t, delivery.Step{ID: id, Tool: tool, Node: node, State: state, Started: started})))
|
||||
_, _ = k.put(key, json.RawMessage(mustStep(t, delivery.Step{ID: id, Tool: tool, Node: node, State: state, Started: started, Owner: owner})))
|
||||
}
|
||||
put("ace", "old1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
put("ace", "mjk3l9x0abcdef", "check ask", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
put("ace", "mjk3l9x0abcdee", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute))
|
||||
put("ace", "raced1", "build_go_test", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
put("ace", "new1", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second))
|
||||
put("ace", "done1", "build_go_fetch", delivery.StepPassed, boot.Add(-time.Hour))
|
||||
put("g14", "other1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
put("ace", "old1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), "")
|
||||
put("ace", "mjk3l9x0abcdef", "check ask", delivery.StepRunning, boot.Add(-time.Hour), "")
|
||||
put("ace", "mjk3l9x0abcdee", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), "")
|
||||
put("ace", "mjk3l9x0abcded", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), gone) // note 21's ghost
|
||||
put("ace", "gone2", "build_go_test", delivery.StepRunning, boot.Add(time.Second), gone)
|
||||
put("ace", "mine1", "check ask", delivery.StepRunning, boot.Add(-time.Hour), Owner)
|
||||
put("ace", "raced1", "build_go_test", delivery.StepRunning, boot.Add(-time.Hour), "")
|
||||
put("ace", "new1", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), "")
|
||||
put("ace", "done1", "build_go_fetch", delivery.StepPassed, boot.Add(-time.Hour), gone)
|
||||
put("g14", "other1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), gone)
|
||||
// raced1 is ended by its own run between the read and the write.
|
||||
raced := false
|
||||
scan := scanOf(k, func(key string) {
|
||||
if key == "ace.raced1" && !raced {
|
||||
raced = true
|
||||
put("ace", "raced1", "build_go_test", delivery.StepPassed, boot.Add(-time.Hour))
|
||||
put("ace", "raced1", "build_go_test", delivery.StepPassed, boot.Add(-time.Hour), "")
|
||||
}
|
||||
})
|
||||
ended, err := EndStale(scan, "ace", boot, nil)
|
||||
if err != nil || len(ended) != 2 || ended[0] == ended[1] {
|
||||
var said []string
|
||||
ended, err := EndStale(scan, "ace", boot, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) })
|
||||
if err != nil || len(ended) != 4 {
|
||||
t.Fatalf("ended %v, %v", ended, err)
|
||||
}
|
||||
if s := k.last("ace.old1"); s.State != delivery.StepFailed || s.Reason != RestartReason || s.Ended.IsZero() {
|
||||
t.Errorf("old1 = %+v", s)
|
||||
if _, held := k.puts["ace.mjk3l9x0abcded"]; held {
|
||||
t.Errorf("the ghost ask's key is still held: %+v", k.last("ace.mjk3l9x0abcded"))
|
||||
}
|
||||
for _, key := range []string{"ace.old1", "ace.gone2"} {
|
||||
if s := k.last(key); s.State != delivery.StepFailed || s.Reason != RestartReason || s.Ended.IsZero() {
|
||||
t.Errorf("%s = %+v", key, s)
|
||||
}
|
||||
}
|
||||
if s := k.last("ace.mjk3l9x0abcdef"); s.State != delivery.StepFailed || !strings.HasPrefix(s.Reason, "past the step bound: its asker stopped waiting at ") {
|
||||
t.Errorf("the old ask = %+v", s)
|
||||
}
|
||||
for key, state := range map[string]string{"ace.mjk3l9x0abcdee": delivery.StepRunning, "ace.raced1": delivery.StepPassed, "ace.new1": delivery.StepRunning, "ace.done1": delivery.StepPassed, "g14.other1": delivery.StepRunning} {
|
||||
if log := strings.Join(said, "\n"); !strings.Contains(log, "ace.mjk3l9x0abcded") || !strings.Contains(log, "deleted, left to the stream's redelivery") || !strings.Contains(log, "its owner "+gone+" is not this process "+Owner) {
|
||||
t.Errorf("the log does not name the ghost and its owner:\n%s", log)
|
||||
}
|
||||
for key, state := range map[string]string{"ace.mjk3l9x0abcdee": delivery.StepRunning, "ace.mine1": delivery.StepRunning, "ace.raced1": delivery.StepPassed,
|
||||
"ace.new1": delivery.StepRunning, "ace.done1": delivery.StepPassed, "g14.other1": delivery.StepRunning} {
|
||||
if s := k.last(key); s.State != state {
|
||||
t.Errorf("%s = %s, want %s", key, s.State, state)
|
||||
}
|
||||
}
|
||||
// EndOne: refuses without why, a held step, one started by this process (a pulled ask or a step tool's), a step not
|
||||
// running and another node's; ends a step a previous process left.
|
||||
for _, c := range []struct{ id, why string }{{"old1", ""}, {"new1", "stuck"}, {"done1", "x"}, {"g14.other1", "x"}} {
|
||||
// EndOne: refuses without why, a held step, one this process owns or (with no owner) started, a step not running and
|
||||
// another node's; ends a step a previous process left.
|
||||
for _, c := range []struct{ id, why string }{{"old1", ""}, {"new1", "stuck"}, {"mine1", "stuck"}, {"done1", "x"}, {"g14.other1", "x"}} {
|
||||
if _, err := EndOne(scan, "ace", c.id, c.why, nil); err == nil {
|
||||
t.Errorf("ended %s with why %q", c.id, c.why)
|
||||
}
|
||||
}
|
||||
put("ace", "old2", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
put("ace", "old2", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), gone)
|
||||
if _, err := EndOne(scan, "ace", "old2", "x", func(string) bool { return true }); err == nil {
|
||||
t.Error("ended a held step")
|
||||
}
|
||||
if _, err := EndOne(scan, "ace", "ace.old2", "stuck for good", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := k.last("ace.old2"); s.State != delivery.StepFailed || s.Reason != "stuck for good" {
|
||||
if s := k.last("ace.old2"); s.State != delivery.StepFailed || s.Reason != "stuck for good" || s.Owner != gone {
|
||||
t.Errorf("old2 = %+v", s)
|
||||
}
|
||||
if a, b := ownerOf("b", 1, boot), ownerOf("b", 1, boot.Add(time.Second)); a == b {
|
||||
t.Errorf("two starts of one pid share the owner %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStep(t *testing.T, s delivery.Step) []byte {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once
|
||||
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
|
||||
// (issue 1473) and the fetch of one commit over SSH (issue 1474).
|
||||
// (issue 1473), the fetch of one commit over SSH (issue 1474), and the failure contract of a test level with the parsers
|
||||
// of go test -json and node --test's TAP that fill it (failure.go, issue 1901; its conformance kit is package conformance).
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
@@ -51,9 +52,9 @@ func Start(ctx context.Context, put Put, node, tool string, run func(ctx context
|
||||
return step.ID, nil
|
||||
}
|
||||
|
||||
// begin writes the step running under id.
|
||||
// begin writes the step running under id, owned by this process (Owner).
|
||||
func begin(put Put, node, tool, id, what string) (delivery.Step, string, error) {
|
||||
step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC(), What: what}
|
||||
step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC(), What: what, Owner: Owner}
|
||||
key, err := delivery.StepKey(node, step.ID)
|
||||
if err != nil {
|
||||
return delivery.Step{}, "", err
|
||||
|
||||
@@ -70,8 +70,9 @@ func TestStartAnswersAtOnceWithTheStepRunning(t *testing.T) {
|
||||
t.Errorf("Start took %v", d)
|
||||
}
|
||||
s := k.last("ace." + id)
|
||||
if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() {
|
||||
t.Errorf("step = %+v", s)
|
||||
// 1890/AC5: the running key names this process as its owner.
|
||||
if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() || s.Owner != Owner || Owner == "" {
|
||||
t.Errorf("step = %+v, want owner %q", s, Owner)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) {
|
||||
t.Fatalf("ended key %q", got)
|
||||
}
|
||||
s := k.last("ace." + id)
|
||||
if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() {
|
||||
if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() || s.Owner != Owner {
|
||||
t.Errorf("step = %+v", s)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package buildstep
|
||||
|
||||
// The reader of `node --test` under its TAP reporter (issue 1901's thorough fix; first written in mesh-build-node by
|
||||
// fix-1901ts): each failing test with its name, its file, its cause and its own block, never a passing line of an
|
||||
// earlier file, which is what node's default reporter left the first fifteen lines of.
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NodeTestOptions is the NODE_OPTIONS a build module runs a package's test script with: node's TAP reporter, written to
|
||||
// the file dest, so whatever the script prints on its own output the report is whole and machine-readable. A script
|
||||
// that names a reporter of its own makes node refuse the run loudly ("--test-reporter must match the number of
|
||||
// --test-reporter-destination"), never quietly.
|
||||
func NodeTestOptions(dest string) string {
|
||||
return "--test-reporter=tap --test-reporter-destination=" + dest
|
||||
}
|
||||
|
||||
var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
|
||||
|
||||
func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") }
|
||||
|
||||
// ReadTAP reads the failing tests of a `node --test` run: report is what the TAP reporter wrote (NodeTestOptions'
|
||||
// file), console the script's own stdout and stderr, script the name of the script that ran and dir the package's
|
||||
// directory, which a test's file is made relative to. A suite that failed only because a test inside it did is not
|
||||
// reported twice. A test file that did not load (a syntax error, a crash, an error after its test ended) takes its
|
||||
// cause from the lines node wrote before it. When the report holds no failing test (node itself failed, or the script
|
||||
// failed before its tests ran: a build, a reporter refused) the failure is the console's (ConsoleFailure).
|
||||
func ReadTAP(pkg, script, dir, report, console string) []Failure {
|
||||
var failed []Failure
|
||||
all := strings.Split(stripANSI(strings.ReplaceAll(report, "\r\n", "\n")), "\n")
|
||||
var cancelled []Failure // failed because a suite around them did: said after the failure that carries the cause
|
||||
prev := 0 // the line after the last test block, passing or not: what the run wrote since belongs to the next test
|
||||
for i := 0; i < len(all); i++ {
|
||||
name, ok := tapNotOk(all[i])
|
||||
if !ok {
|
||||
if tapOk(all[i]) {
|
||||
i = tapBlockEnd(all, i) - 1
|
||||
prev = i + 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
indent := len(all[i]) - len(strings.TrimLeft(all[i], " "))
|
||||
block := []string{strings.TrimSpace(all[i])}
|
||||
fields := map[string]string{}
|
||||
var key string
|
||||
inYAML := false
|
||||
j := i + 1
|
||||
for ; j < len(all); j++ {
|
||||
t := strings.TrimSpace(all[j])
|
||||
if !inYAML {
|
||||
if t == "---" {
|
||||
inYAML = true
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if t == "..." {
|
||||
j++
|
||||
break
|
||||
}
|
||||
block = append(block, strings.TrimRight(all[j], " \t"))
|
||||
if k, v, found := tapKey(all[j], indent); found {
|
||||
key = k
|
||||
if v != "|-" && v != "|" {
|
||||
fields[k] = strings.Trim(v, "'")
|
||||
}
|
||||
} else if key != "" && fields[key] == "" && t != "" {
|
||||
fields[key] = t // the first line of a block scalar
|
||||
}
|
||||
}
|
||||
from, at := prev, i
|
||||
i, prev = j-1, j
|
||||
if fields["failureType"] == "subtestsFailed" {
|
||||
continue
|
||||
}
|
||||
cause := fields["error"]
|
||||
if cause == "test failed" || cause == "" {
|
||||
// a file that did not load, or an error after a test ended: node says only "test failed" and wrote the
|
||||
// error as comment lines before it
|
||||
var own []string
|
||||
for _, l := range all[from:at] {
|
||||
if t := strings.TrimSpace(l); strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "# Subtest") {
|
||||
own = append(own, strings.TrimPrefix(t, "# "))
|
||||
}
|
||||
}
|
||||
for _, l := range own {
|
||||
if strings.Contains(l, "Error") {
|
||||
cause = l
|
||||
break
|
||||
}
|
||||
}
|
||||
if cause == "" && len(own) > 0 {
|
||||
cause = own[0]
|
||||
}
|
||||
block = append(own, block...)
|
||||
}
|
||||
if cause == "" {
|
||||
cause = fields["failureType"]
|
||||
}
|
||||
if cause == "" {
|
||||
cause = "the test failed and node said no error"
|
||||
}
|
||||
cause = CutLine(cause)
|
||||
file := strings.TrimPrefix(fields["location"], "file://")
|
||||
if dir != "" {
|
||||
file = strings.TrimPrefix(file, strings.TrimSuffix(dir, "/")+"/")
|
||||
}
|
||||
f := Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause)}
|
||||
if fields["failureType"] == "cancelledByParent" {
|
||||
cancelled = append(cancelled, f)
|
||||
continue
|
||||
}
|
||||
failed = append(failed, f)
|
||||
}
|
||||
if failed = append(failed, cancelled...); len(failed) > 0 {
|
||||
return failed
|
||||
}
|
||||
var comments []string
|
||||
for _, l := range all {
|
||||
if t := strings.TrimSpace(l); strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "# Subtest") && !tapCount(t) {
|
||||
comments = append(comments, strings.TrimPrefix(t, "# "))
|
||||
}
|
||||
}
|
||||
return []Failure{ConsoleFailure(pkg, script, strings.Join(comments, "\n")+"\n"+console)}
|
||||
}
|
||||
|
||||
// tapCount says a comment is TAP's closing count ("# tests 3", "# pass 2", "# duration_ms 1.2").
|
||||
func tapCount(t string) bool {
|
||||
for _, w := range []string{"tests", "suites", "pass", "fail", "cancelled", "skipped", "todo", "duration_ms"} {
|
||||
if strings.HasPrefix(t, "# "+w+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tapOk says a line is a passing test's "ok N - name", indented or not.
|
||||
func tapOk(l string) bool {
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(l), "ok ")
|
||||
return ok && rest != "" && rest[0] >= '0' && rest[0] <= '9'
|
||||
}
|
||||
|
||||
// tapBlockEnd is the line after the test line at i and its YAML block ("---" to "...").
|
||||
func tapBlockEnd(all []string, i int) int {
|
||||
j := i + 1
|
||||
if j >= len(all) || strings.TrimSpace(all[j]) != "---" {
|
||||
return j
|
||||
}
|
||||
for j++; j < len(all); j++ {
|
||||
if strings.TrimSpace(all[j]) == "..." {
|
||||
return j + 1
|
||||
}
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// tapNotOk is the test name of a "not ok N - name" line, indented or not; a TODO or SKIP directive fails nothing.
|
||||
func tapNotOk(l string) (string, bool) {
|
||||
t := strings.TrimSpace(l)
|
||||
rest, ok := strings.CutPrefix(t, "not ok ")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if u := strings.ToUpper(rest); strings.Contains(u, " # TODO") || strings.Contains(u, " # SKIP") {
|
||||
return "", false
|
||||
}
|
||||
if _, after, found := strings.Cut(rest, " - "); found {
|
||||
rest = after
|
||||
}
|
||||
return strings.TrimSpace(rest), true
|
||||
}
|
||||
|
||||
// tapKey is "key: value" of a field of a TAP block, whose lines sit two spaces deeper than their "not ok" line; a deeper
|
||||
// line is part of a block scalar (an error's lines, a stack).
|
||||
func tapKey(l string, indent int) (key, val string, ok bool) {
|
||||
t := strings.TrimLeft(l, " ")
|
||||
if len(l)-len(t) != indent+2 {
|
||||
return "", "", false
|
||||
}
|
||||
k, v, found := strings.Cut(t, ": ")
|
||||
if !found {
|
||||
if k, found = strings.CutSuffix(t, ":"); !found {
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
if k == "" || strings.ContainsAny(k, " ()/") {
|
||||
return "", "", false
|
||||
}
|
||||
return k, strings.TrimSpace(v), true
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **The accounts the grants part wants, and the one shape a held account takes** (novox/hq ADR 0561 §1, §2; issue 774).
|
||||
//
|
||||
// The bus composer derives every user from placement, the manifests and the wants. For each account the mesh mints (a
|
||||
// module's, a module's process's, a machine's provisioner client's), the grants part writes one key of its state
|
||||
// `accounts`, keyed by the bus user, holding the account's fields that are not secret: what the controller's credential
|
||||
// carries today beside the password. The vault watches that state, mints the user it holds no value for, and keeps the
|
||||
// value as the module's own secret on that node, built by HeldCredential, which gives byte for byte the controller's
|
||||
// shape, so a runtime reads it unchanged. The state is read by the vault alone (NamedReaders), as `bus-accounts` is read
|
||||
// by the grants part alone.
|
||||
//
|
||||
// The node accounts, the controller's own, an enrolment user and a person are never wanted here: the vault mints none
|
||||
// of them (ADR 0561 §4, §5).
|
||||
|
||||
// AccountsState is the grants part's state of wanted accounts, by bus user.
|
||||
const AccountsState = "accounts"
|
||||
|
||||
// GrantsAccounts is that state as a reader names it (`<owner>.<name>`).
|
||||
const GrantsAccounts = GrantsModule + "." + AccountsState
|
||||
|
||||
// AccountsBucket is the bucket the state lives in.
|
||||
var AccountsBucket = BucketName(GrantsModule, AccountsState)
|
||||
|
||||
// AccountsSubject is the subject of one wanted account's key: `$KV.mesh-grants_accounts.<user>`.
|
||||
func AccountsSubject(user string) string { return "$KV." + AccountsBucket + "." + user }
|
||||
|
||||
// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash.
|
||||
const AccountIssuedEvent = "account-issued"
|
||||
|
||||
// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events.
|
||||
const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent
|
||||
|
||||
// ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a
|
||||
// node-scoped seat's verb carries the machine) and the verbs the runtime serves for it.
|
||||
type ClaimedSeat struct {
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves,omitempty"`
|
||||
}
|
||||
|
||||
// WantedAccount is one key of the grants part's state `accounts`: an account the mesh needs, without its password.
|
||||
type WantedAccount struct {
|
||||
User string `json:"user"`
|
||||
// Kind is KindModule (KindNodeTools for the runtime, named as a module is), KindProcess or KindProvisioner.
|
||||
Kind Kind `json:"kind"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
// Process is a KindProcess account's process id.
|
||||
Process string `json:"process,omitempty"`
|
||||
// URL is the bus's address as that node reaches it, `nats://<host>:<port>`; Fingerprint the bus certificate's.
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
// Claims are a module's claims with the verbs each seat promises: none for a process or a provisioner client.
|
||||
Claims []ClaimedSeat `json:"claims,omitempty"`
|
||||
// At is when the grants part wrote the key: not compared (Same).
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// mintedKinds are the kinds of account the vault mints (ADR 0561 §1).
|
||||
var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner}
|
||||
|
||||
// principal is the account as the composer names it.
|
||||
func (w WantedAccount) principal() Principal {
|
||||
return Principal{Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process}
|
||||
}
|
||||
|
||||
// Valid says why w is not an account the vault may mint, or nil: a kind it mints, every name a subject token, the user
|
||||
// the one the composer derives from them, and an address to reach the bus at.
|
||||
func (w WantedAccount) Valid() error {
|
||||
if !slices.Contains(mintedKinds, w.Kind) {
|
||||
return fmt.Errorf("%s is a %s account, which the vault never mints (ADR 0561 §4)", w.User, w.Kind)
|
||||
}
|
||||
for what, v := range map[string]string{"node": w.Node, "module": w.Module} {
|
||||
if !safeSubject.MatchString(v) {
|
||||
return fmt.Errorf("%s: the %s %q is not a name the mesh writes", w.User, what, v)
|
||||
}
|
||||
}
|
||||
if w.Kind == KindProcess && !safeSubject.MatchString(w.Process) {
|
||||
return fmt.Errorf("%s: the process %q is not a name the mesh writes", w.User, w.Process)
|
||||
}
|
||||
if w.Kind != KindProcess && w.Process != "" {
|
||||
return fmt.Errorf("%s: only a process's account names a process", w.User)
|
||||
}
|
||||
if want := w.principal().Username(); w.User != want {
|
||||
return fmt.Errorf("%s is not the user of a %s account of %s on %s (that is %s)", w.User, w.Kind, w.Module, w.Node, want)
|
||||
}
|
||||
if !strings.HasPrefix(w.URL, "nats://") || len(w.URL) == len("nats://") {
|
||||
return fmt.Errorf("%s names no bus address (url, nats://<host>:<port>)", w.User)
|
||||
}
|
||||
if len(w.Claims) > 0 && w.Kind != KindModule && w.Kind != KindNodeTools {
|
||||
return fmt.Errorf("%s: a %s account holds no seat, so it carries no claims", w.User, w.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Same says whether two wanted accounts want the same account: every field but when it was written.
|
||||
func (w WantedAccount) Same(o WantedAccount) bool {
|
||||
a, b := w, o
|
||||
a.At, b.At = time.Time{}, time.Time{}
|
||||
ra, _ := json.Marshal(a)
|
||||
rb, _ := json.Marshal(b)
|
||||
return string(ra) == string(rb)
|
||||
}
|
||||
|
||||
// OwnSecret is the module's own secret the account is held in: `broker`, `broker-<process id>` or `provisioner-broker`
|
||||
// (the controller's names, ADR 0436 §2, ADR 0349 §11).
|
||||
func (w WantedAccount) OwnSecret() string {
|
||||
switch w.Kind {
|
||||
case KindProcess:
|
||||
return "broker-" + w.Process
|
||||
case KindProvisioner:
|
||||
return "provisioner-broker"
|
||||
}
|
||||
return "broker"
|
||||
}
|
||||
|
||||
// ReadWantedAccount reads the key user of the state: refused when it is not a valid wanted account of that user.
|
||||
func ReadWantedAccount(user string, raw []byte) (WantedAccount, error) {
|
||||
var w WantedAccount
|
||||
if err := json.Unmarshal(raw, &w); err != nil {
|
||||
return WantedAccount{}, fmt.Errorf("%s %s is not a wanted account: %w", GrantsAccounts, user, err)
|
||||
}
|
||||
if w.User != user {
|
||||
return WantedAccount{}, fmt.Errorf("%s %s names the user %q", GrantsAccounts, user, w.User)
|
||||
}
|
||||
return w, w.Valid()
|
||||
}
|
||||
|
||||
// AccountIssued is the vault's account-issued: which account it minted, and its generation. Never a value or a hash.
|
||||
type AccountIssued struct {
|
||||
User string `json:"user"`
|
||||
Kind Kind `json:"kind"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Process string `json:"process,omitempty"`
|
||||
Generation int `json:"generation"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith,
|
||||
// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a
|
||||
// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out.
|
||||
func HeldCredential(w WantedAccount, password string) ([]byte, error) {
|
||||
if err := w.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("%s: an account is held with its password", w.User)
|
||||
}
|
||||
switch w.Kind {
|
||||
case KindProcess:
|
||||
return json.Marshal(struct {
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password})
|
||||
case KindProvisioner:
|
||||
return json.Marshal(struct {
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Node string `json:"node"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password})
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Claims []ClaimedSeat `json:"claims,omitempty"`
|
||||
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password,
|
||||
Claims: w.Claims})
|
||||
}
|
||||
|
||||
// **One issuer at every moment: minting turns on at the lease write** (novox/hq ADR 0561 §3, §6). The controller's lease
|
||||
// is the one key `holder` of its bucket (LeaseBucket). The operator's credential hands it away by writing a value that
|
||||
// names its new holder in `handed-to`; while the key holds that, the vault mints and the grants part creates the durable
|
||||
// consumers an account needs. A controller's value, a key deleted or expired, or one that cannot be read or parsed, turn
|
||||
// both off: when in doubt nothing is minted, so the controller and the vault never both mint one account. No clock and
|
||||
// no setting turns it.
|
||||
|
||||
// ControllerLease is the lease's bucket as a reader names it; the vault and the grants part alone read it
|
||||
// (NamedReaders), the controller's announcement marking it readable by nobody.
|
||||
const ControllerLease = ControllerSeat + ".lease"
|
||||
|
||||
// ControllerLeaseKey is the lease's one key.
|
||||
const ControllerLeaseKey = leaseKey
|
||||
|
||||
// LeaseHolder is the lease key's value: a controller instance's (mesh-controller internal/lease Holder), or, once handed
|
||||
// away, one naming its holder in HandedTo.
|
||||
type LeaseHolder struct {
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Epoch uint64 `json:"epoch,omitempty"`
|
||||
Taken time.Time `json:"taken,omitzero"`
|
||||
Renewed time.Time `json:"renewed,omitzero"`
|
||||
// HandedTo is who holds the lease once the controller handed it away: never a controller instance.
|
||||
HandedTo string `json:"handed-to,omitempty"`
|
||||
// HandedBy is the credential that wrote it (the operator's), and Why its reason.
|
||||
HandedBy string `json:"handed-by,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and
|
||||
// says whether it is handed to a holder that is no controller, with why either way.
|
||||
func LeaseHandedAway(raw []byte, found bool, readErr error) (bool, string) {
|
||||
switch {
|
||||
case readErr != nil:
|
||||
return false, "the controller's lease cannot be read (" + readErr.Error() + "), so nothing is minted"
|
||||
case !found || len(raw) == 0 || string(raw) == "null":
|
||||
return false, "no lease is written (expired, deleted, or never taken): a controller may take it, so nothing is minted"
|
||||
}
|
||||
var h LeaseHolder
|
||||
if err := json.Unmarshal(raw, &h); err != nil {
|
||||
return false, "the controller's lease is not readable as a holder (" + err.Error() + "), so nothing is minted"
|
||||
}
|
||||
if strings.TrimSpace(h.HandedTo) == "" {
|
||||
who := h.Instance
|
||||
if who == "" {
|
||||
who = "an instance that names itself not"
|
||||
}
|
||||
return false, "the controller holds the lease (" + who + "): it mints, so the vault mints nothing (ADR 0561 §3)"
|
||||
}
|
||||
if h.Instance != "" {
|
||||
return false, "the lease names both a controller instance (" + h.Instance + ") and a holder it is handed to (" +
|
||||
h.HandedTo + "), so nothing is minted"
|
||||
}
|
||||
return true, "the lease is handed to " + h.HandedTo + ": the vault mints every new account (ADR 0561 §1, §3)"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -58,6 +59,10 @@ func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announ
|
||||
switch {
|
||||
case reader != "" && !ReaderMay(reader, r):
|
||||
refused = append(refused, r)
|
||||
case reader != "" && namedReader(reader, r):
|
||||
// A state the composer names its readers for is theirs whatever its owner announced (ADR 0561 §3: the
|
||||
// controller's lease, which its announcement marks readable by nobody).
|
||||
granted = append(granted, r)
|
||||
case reader != "" && ok && said && s.Keeps(name):
|
||||
if s.ReadableBy(name, reader) {
|
||||
granted = append(granted, r)
|
||||
@@ -95,3 +100,8 @@ func NotAnnounced(r Records) []string {
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// namedReader says the composer names module among the readers of read (NamedReaders).
|
||||
func namedReader(module, read string) bool {
|
||||
return slices.Contains(NamedReaders[strings.TrimSpace(read)], module)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ package buscompose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -49,6 +55,26 @@ func mayBeyondTheControllers(user string) map[string]bool {
|
||||
VaultSecretChanged: true}
|
||||
}
|
||||
|
||||
// engineBeyond is a node's engine holding its node seat and asking the operator in its own name (novox/hq ADR 0563 §1,
|
||||
// §3), beyond the controller's on purpose: its own node's verbs, events and discovery, the bus's discovery asked, and
|
||||
// the router's seat traffic under node-engine-<node>.
|
||||
func engineBeyond(user, g string) bool {
|
||||
node, ok := strings.CutPrefix(user, "node.")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) ||
|
||||
strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) ||
|
||||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".")
|
||||
}
|
||||
|
||||
// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream
|
||||
// and name (novox/hq ADR 0561 §6): beyond the controller's on purpose, since the controller made them alone.
|
||||
func createsAnAccountsConsumer(user, g string) bool {
|
||||
return strings.HasSuffix(user, "."+GrantsModule) &&
|
||||
(strings.HasPrefix(g, "$JS.API.CONSUMER.CREATE.") || strings.HasPrefix(g, "$JS.API.CONSUMER.INFO."))
|
||||
}
|
||||
|
||||
// quotedToken is a quoted token of a composed list.
|
||||
var quotedToken = regexp.MustCompile(`"[^"]*"`)
|
||||
|
||||
@@ -100,7 +126,8 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
}
|
||||
}
|
||||
for g := range now {
|
||||
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) {
|
||||
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) &&
|
||||
!createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) {
|
||||
t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +144,8 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
}
|
||||
}
|
||||
|
||||
const SignersBucketName = "mesh-vault_signers"
|
||||
|
||||
func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json"))
|
||||
if err != nil {
|
||||
@@ -205,6 +234,55 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS")
|
||||
}
|
||||
})
|
||||
// ADR 0563 §1, §3, §6: each node's engine alone answers its own node's node-engine verbs and says its events, any
|
||||
// runtime may call them, and it asks the operator under node-engine-<node> alone.
|
||||
t.Run("a node's engine holds its node seat and asks in its own name", func(t *testing.T) {
|
||||
router := Seat{Name: AsksSeat, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
|
||||
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}}
|
||||
users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{
|
||||
"one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}},
|
||||
"two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range users {
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answers := MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") ||
|
||||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".call.root-free.one.messenger~one")
|
||||
if engineOne := u.Kind == KindNode && u.Node == "one"; answers != engineOne {
|
||||
t.Errorf("%s answers one's root-free: %v", u.Username(), answers)
|
||||
}
|
||||
switch {
|
||||
case u.Kind == KindNode && u.Node == "one":
|
||||
for subject, want := range map[string]bool{
|
||||
"mesh.seat.operator-channel.accept.ask.node-engine-one": true,
|
||||
"mesh.seat.operator-channel.accept.cancel.node-engine-one": true,
|
||||
"mesh.seat.operator-channel.accept.ask.node-engine-two": false,
|
||||
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-one.hand-over-1": true,
|
||||
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-two.hand-over-1": false,
|
||||
"mesh.seat." + EngineSeat + ".event.done.one": true,
|
||||
"mesh.seat." + EngineSeat + ".event.done.two": false,
|
||||
"$SRV.INFO": true,
|
||||
} {
|
||||
if got := MayPublish(perms, subject); got != want {
|
||||
t.Errorf("node one may publish %s: %v, want %v", subject, got, want)
|
||||
}
|
||||
}
|
||||
if !MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-one") ||
|
||||
MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-two") ||
|
||||
!MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") {
|
||||
t.Errorf("node one hears %v", perms.Subscribe)
|
||||
}
|
||||
case u.Kind == KindNodeTools || u.Module == "messenger":
|
||||
if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") {
|
||||
t.Errorf("%s cannot call one's root-free", u.Username())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no
|
||||
// other node's.
|
||||
t.Run("a node asks the vault its own secrets", func(t *testing.T) {
|
||||
@@ -223,6 +301,46 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
if !slices.Contains(perms.Subscribe, VaultSecretChanged) {
|
||||
t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe)
|
||||
}
|
||||
// L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's
|
||||
// `signers` and the lease key, never writing either.
|
||||
signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true})
|
||||
signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key"
|
||||
leaseGet := "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder"
|
||||
for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true, leaseGet: true,
|
||||
VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false,
|
||||
"$KV." + LeaseBucket + ".holder": false} {
|
||||
if MayPublish(signs, subject) != want {
|
||||
t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want)
|
||||
}
|
||||
}
|
||||
if MayPublish(perms, VaultSignCall(one)) || MayPublish(perms, signersGet) {
|
||||
t.Error("a node asks sign or reads signers while the vault does not serve sign")
|
||||
}
|
||||
// And what a module on another node serves from its settings, that key alone (mailu's smtp domain, issue 1126).
|
||||
users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{
|
||||
"two": {{Module: "mailer", Served: []string{"domain", "not.a.token"}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get := func(key string) string {
|
||||
return "$JS.API.DIRECT.GET.KV_" + ResolvedBucket + ".$KV." + ResolvedBucket + "." + key
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind != KindNode {
|
||||
continue
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Node == "one" && (!reflect.DeepEqual(u.ServedReads, []string{"two.mailer.domain"}) || !MayPublish(perms, get("two.mailer.domain")) ||
|
||||
MayPublish(perms, get("two.mailer.sitename"))) {
|
||||
t.Errorf("node one reads %v of two's resolved values", u.ServedReads)
|
||||
}
|
||||
if u.Node == "two" && len(u.ServedReads) != 0 {
|
||||
t.Errorf("node two is granted its own served keys apart: %v", u.ServedReads)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("the controller's golden server configuration", func(t *testing.T) {
|
||||
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
|
||||
@@ -252,4 +370,174 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
}
|
||||
grantsAsTheControllers(t, got, string(want))
|
||||
})
|
||||
// 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by
|
||||
// its user, at the subject `$KV.mesh-grants_accounts.<user>`, read by the vault alone; and the vault's account-issued
|
||||
// names the account, never a value.
|
||||
t.Run("a wanted account is kept by its user and read by the vault alone", func(t *testing.T) {
|
||||
for _, w := range []WantedAccount{
|
||||
{User: "ace.mesh-build-go", Kind: KindModule, Node: "ace", Module: "mesh-build-go", URL: "nats://novox.mesh:4222",
|
||||
Fingerprint: "ab", Claims: []ClaimedSeat{{Seat: "node-build-go", Scope: "node", Serves: []string{"build"}}}},
|
||||
{User: "novox.mesh-mail.send", Kind: KindProcess, Node: "novox", Module: "mesh-mail", Process: "send",
|
||||
URL: "nats://novox.mesh:4222"},
|
||||
{User: "provisioner.g14", Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client",
|
||||
URL: "nats://novox.mesh:4222", Fingerprint: "ab"},
|
||||
} {
|
||||
if err := w.Valid(); err != nil {
|
||||
t.Fatalf("%s: %v", w.User, err)
|
||||
}
|
||||
raw, err := json.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := ReadWantedAccount(w.User, raw)
|
||||
if err != nil || !back.Same(w) {
|
||||
t.Errorf("%s does not round-trip: %+v, %v", w.User, back, err)
|
||||
}
|
||||
if got := AccountsSubject(w.User); got != "$KV.mesh-grants_accounts."+w.User {
|
||||
t.Errorf("%s is kept at %s", w.User, got)
|
||||
}
|
||||
if _, err := ReadWantedAccount("someone.else", raw); err == nil {
|
||||
t.Errorf("%s was read under another user's key", w.User)
|
||||
}
|
||||
}
|
||||
for _, bad := range []WantedAccount{
|
||||
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"},
|
||||
{User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"},
|
||||
{User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"},
|
||||
{User: "controller", Kind: KindController, URL: "nats://a:1"},
|
||||
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"},
|
||||
} {
|
||||
if bad.Valid() == nil {
|
||||
t.Errorf("%+v is taken as a wanted account", bad)
|
||||
}
|
||||
}
|
||||
if ReaderMay("mesh-delivery-assigner", GrantsAccounts) || !ReaderMay(VaultSeat, GrantsAccounts) {
|
||||
t.Errorf("%s is read by %v alone, want the vault's", GrantsAccounts, NamedReaders[GrantsAccounts])
|
||||
}
|
||||
e := AccountIssued{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x", Generation: 1}
|
||||
raw, _ := json.Marshal(e)
|
||||
if strings.Contains(string(raw), "password") || strings.Contains(string(raw), "hash") ||
|
||||
AccountIssuedSubject != "mesh.mod.mesh-vault.event.account-issued" {
|
||||
t.Errorf("account-issued says %s at %s", raw, AccountIssuedSubject)
|
||||
}
|
||||
})
|
||||
// 774/AC3 (novox/hq ADR 0561 §2): the account the vault seals is, byte for byte, what the controller hands for each of
|
||||
// its three kinds: testdata/controller-credentials.json was written by the controller's own code (its "how").
|
||||
t.Run("a held account is the controller's shape byte for byte", func(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "controller-credentials.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var captured struct {
|
||||
Rows []struct{ Name, Held string }
|
||||
}
|
||||
if err := json.Unmarshal(raw, &captured); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vault := ClaimedSeat{Seat: "mesh-vault", Scope: "mesh", Serves: []string{"get", "accept", "secret-ask", "issued",
|
||||
"rotate", "move-in", "node-key", "own", "move-asks"}}
|
||||
controller := ClaimedSeat{Seat: "mesh-controller", Scope: "mesh", Serves: strings.Split("tools,calls,status,nodes,"+
|
||||
"node,placement-export,modules,seats,builds,plans,delivery-plan,delivery-order,delivery-check,deliver,"+
|
||||
"delivery-stop,delivery-backups-again,delivery-walks,plan,assign,unassign,pin,unpin,push,rotate,give,issued,"+
|
||||
"secret-move,secret-ask,issue,token,settings,command,queue,cancel,clear,replay,try,kill,pause,resume,hand-act,"+
|
||||
"drill,warranted,hand-acts,durations,conditions,healers,doctor,upgrade,bus,retire,cleanup,judge-directories,"+
|
||||
"root-free,dead-letters,data,build,artifacts,held,collect,move-placement,move-config,mirrors,images", ",")}
|
||||
at := "nats://10.0.0.1:4222"
|
||||
wanted := map[string]struct {
|
||||
w WantedAccount
|
||||
password string
|
||||
}{
|
||||
"module": {WantedAccount{User: "laptop.cap-bot", Kind: KindModule, Node: "laptop", Module: "cap-bot", URL: at,
|
||||
Fingerprint: "ab:cd", Claims: []ClaimedSeat{vault, {Seat: "node-service-manager", Scope: "node",
|
||||
Serves: []string{"status"}}, controller, {Seat: "no-such-seat", Scope: "node"}}}, "pw-module"},
|
||||
"module-no-claims-no-fingerprint": {WantedAccount{User: "laptop.cap-plain", Kind: KindModule, Node: "laptop",
|
||||
Module: "cap-plain", URL: at}, "pw-plain"},
|
||||
"provisioner": {WantedAccount{User: "provisioner.laptop", Kind: KindProvisioner, Node: "laptop", Module: "nats",
|
||||
URL: at, Fingerprint: "ab:cd"}, "pw-prov"},
|
||||
"process": {WantedAccount{User: "laptop.mesh-mail.send", Kind: KindProcess, Node: "laptop", Module: "mesh-mail",
|
||||
Process: "send", URL: at, Fingerprint: "ab:cd"}, "pw-proc"},
|
||||
}
|
||||
if len(captured.Rows) != len(wanted) {
|
||||
t.Fatalf("%d rows captured, %d wanted", len(captured.Rows), len(wanted))
|
||||
}
|
||||
for _, row := range captured.Rows {
|
||||
c, ok := wanted[row.Name]
|
||||
if !ok {
|
||||
t.Fatalf("no wanted account for the captured %s", row.Name)
|
||||
}
|
||||
got, err := HeldCredential(c.w, c.password)
|
||||
if err != nil || string(got) != row.Held {
|
||||
t.Errorf("%s:\n got %s (%v)\nwant %s", row.Name, got, err, row.Held)
|
||||
}
|
||||
}
|
||||
if _, err := HeldCredential(wanted["module"].w, ""); err == nil {
|
||||
t.Error("an account was held with no password")
|
||||
}
|
||||
})
|
||||
// 774 subtasks 6 and 8 (novox/hq ADR 0561 §3, §6): minting and the grants part's consumers turn on only while the
|
||||
// lease is handed to a holder that is no controller; the vault and the grants part alone read the lease, whatever the
|
||||
// controller announced; a module's consumer is created as the controller's EnsureConsumer creates it.
|
||||
t.Run("the lease turns minting on only when handed away; a consumer is created as the controller's", func(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
raw string
|
||||
found bool
|
||||
err error
|
||||
on bool
|
||||
}{
|
||||
{"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false},
|
||||
{"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true},
|
||||
{"expired or deleted", ``, false, nil, false},
|
||||
{"unreadable", ``, false, errors.New("refused"), false},
|
||||
{"not a holder", `[1]`, true, nil, false},
|
||||
{"both a controller and a handed holder", `{"instance":"novox/1","handed-to":"x"}`, true, nil, false},
|
||||
} {
|
||||
if on, why := LeaseHandedAway([]byte(row.raw), row.found, row.err); on != row.on || why == "" {
|
||||
t.Errorf("%s: on %v (%s), want %v", row.name, on, why, row.on)
|
||||
}
|
||||
// The assigner's grants source follows it: the grants part's bucket only for a handed-away lease (issue 774).
|
||||
on, _ := LeaseHandedAway([]byte(row.raw), row.found, row.err)
|
||||
if bucket, _ := delivery.GrantSource(on); (bucket == delivery.PartGrantsBucket) != row.on {
|
||||
t.Errorf("%s: the grants source is %s", row.name, bucket)
|
||||
}
|
||||
}
|
||||
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox")}
|
||||
for reader, want := range map[string]bool{VaultSeat: true, GrantsModule: true, delivery.AssignerModule: true, "mesh-delivery-view": false} {
|
||||
granted, _ := ReadsAnnouncedBy(reader, []string{ControllerLease}, announced)
|
||||
if (len(granted) == 1) != want {
|
||||
t.Errorf("%s reads the lease: %v, want %v", reader, granted, want)
|
||||
}
|
||||
}
|
||||
c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}})
|
||||
cfg := ConsumerConfig(c)
|
||||
if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second ||
|
||||
cfg.MaxDeliver != 5 || cfg.DeliverPolicy != jetstream.DeliverNewPolicy || len(cfg.FilterSubjects) != 2 ||
|
||||
cfg.Description == "" {
|
||||
t.Errorf("a module's consumer is created as %+v", cfg)
|
||||
}
|
||||
// The grants part is granted the create of each account's consumer by its stream and name, and no other.
|
||||
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
|
||||
{Module: GrantsModule}, {Module: "m", Consumes: []string{"x.happened"}}, {Module: "quiet"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Module != GrantsModule || u.Kind != KindModule {
|
||||
continue
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_quiet") ||
|
||||
MayPublish(perms, "$JS.API.CONSUMER.DELETE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m.mesh.mod.x.event.happened") {
|
||||
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
|
||||
}
|
||||
}
|
||||
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
|
||||
QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3}))
|
||||
if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy {
|
||||
t.Errorf("a process's queue consumer is created as %+v", q)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,3 +86,30 @@ func ModuleStreamConfig(m ModuleStream) jetstream.StreamConfig {
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ConsumerConfig is a pull consumer as the controller's EnsureConsumer creates it (mesh-controller internal/broker
|
||||
// JetStream.EnsureConsumer, a consumer not there yet): its durable name, explicit acks, its ack wait, max deliver and max
|
||||
// ack pending, its description, one filter or several, and from the stream's end when it is made FromNow. The grants part
|
||||
// creates a module's and a process's consumers with it from the lease write (novox/hq ADR 0561 §6): a create alone,
|
||||
// never an update of one the bus holds. A push consumer or a queue group is the controller's own and never made here.
|
||||
func ConsumerConfig(c Consumer) jetstream.ConsumerConfig {
|
||||
cfg := jetstream.ConsumerConfig{
|
||||
Durable: c.Name,
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
AckWait: time.Duration(c.AckWaitSeconds) * time.Second,
|
||||
MaxDeliver: c.MaxDeliver,
|
||||
MaxAckPending: c.MaxAckPending,
|
||||
Description: c.Why,
|
||||
}
|
||||
switch len(c.Filters) {
|
||||
case 0:
|
||||
case 1:
|
||||
cfg.FilterSubject = c.Filters[0]
|
||||
default:
|
||||
cfg.FilterSubjects = c.Filters
|
||||
}
|
||||
if c.FromNow {
|
||||
cfg.DeliverPolicy = jetstream.DeliverNewPolicy
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}},
|
||||
{Module: RuntimeModule},
|
||||
}},
|
||||
// The node-engine's own manifest, placed on every node though no node is assigned it (novox/hq ADR 0563 §2, §3).
|
||||
Engine: &Declared{Module: EngineModule, State: []Bucket{{Module: EngineModule, Name: EngineActsState}}},
|
||||
}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
@@ -64,6 +66,15 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
|
||||
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
|
||||
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
|
||||
// The node-engine's acts: created by the grants part, written by each node's engine under its own keys alone.
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-host_acts", true},
|
||||
{"node.ace", "$JS.API.STREAM.CREATE.KV_mesh-host_acts", false},
|
||||
{"node.ace", "$KV.mesh-host_acts.ace.act.hand-over-1", true},
|
||||
{"node.ace", "$KV.mesh-host_acts.ace.open.hand-over.abc", true},
|
||||
{"node.ace", "$KV.mesh-host_acts.novox.act.hand-over-1", false},
|
||||
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.ask.hand-over-1", true},
|
||||
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.ask.hand-over-1", false},
|
||||
{"novox.node-tools", "$KV.mesh-host_acts.novox.act.hand-over-1", false},
|
||||
// The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other.
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true},
|
||||
{GrantsModule, "$O.mesh-grants-user-list.C.abc", true},
|
||||
|
||||
@@ -247,6 +247,30 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
|
||||
return out
|
||||
}
|
||||
|
||||
// AccountConsumers are the durable consumers an account needs (novox/hq ADR 0561 §6): of ConsumersOf, each module's on
|
||||
// EVENTS and each process's on its own work queue, never one on a module stream it hears (those the controller makes with
|
||||
// the stream's grant). The grants part creates them from the lease write, with these configurations.
|
||||
func AccountConsumers(users []Principal) []ModuleConsumer {
|
||||
queues := map[string]bool{}
|
||||
for _, p := range users {
|
||||
for _, q := range p.Queues {
|
||||
queues[q.Stream] = true
|
||||
}
|
||||
for _, d := range p.Carries {
|
||||
for _, q := range d.Queues {
|
||||
queues[q.Stream] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []ModuleConsumer
|
||||
for _, mc := range ConsumersOf(users) {
|
||||
if mc.Consumer.Stream == consumerStream(Principal{Kind: KindModule}) || queues[mc.Consumer.Stream] {
|
||||
out = append(out, mc)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HolderConsumerFor is the worker a seat's holders share on that seat's work queue.
|
||||
//
|
||||
// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module
|
||||
// claims it. Its verbs and events are the catalogue's (catalogue.EngineVerbs), read here rather than copied.
|
||||
|
||||
// EngineSeat is the seat.
|
||||
const EngineSeat = catalogue.EngineSeat
|
||||
|
||||
// EngineModule is the node-engine's manifest's module name, and EngineActsState its state the acts asked through the
|
||||
// node seat and its own asks are kept in (ADR 0563 §2, §3: "the engine's own state"): the bucket mesh-host_acts, keys
|
||||
// `<node>.act.<id>`, `<node>.open.<act>.<digest>` and `<node>.ask.<id>`, each written by that node's engine alone.
|
||||
const (
|
||||
EngineModule = "mesh-host"
|
||||
EngineActsState = "acts"
|
||||
)
|
||||
|
||||
// EngineActsBucket is that state's bucket.
|
||||
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
|
||||
|
||||
// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it
|
||||
// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the
|
||||
// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR
|
||||
// 0563 §6), and asks the operator under asks.EngineAsker(node) on the router's seat as its holder declares it, as any
|
||||
// user of that seat is granted.
|
||||
func engineGrants(p Principal) (pub, sub []string) {
|
||||
for _, v := range catalogue.EngineVerbs() {
|
||||
sub = append(sub, "mesh.seat."+EngineSeat+".tool."+v.Name+"."+p.Node)
|
||||
}
|
||||
for _, e := range catalogue.EngineEvents {
|
||||
pub = append(pub, "mesh.seat."+EngineSeat+".event."+e+"."+p.Node)
|
||||
}
|
||||
sub = append(sub, announcing(EngineSeat)...)
|
||||
pub = append(pub, discovering()...)
|
||||
tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil))
|
||||
// And its acts and asks, under its own node's keys alone; never the bucket's creation, which is the grants part's.
|
||||
if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil {
|
||||
pub = append(pub, own...)
|
||||
}
|
||||
return append(pub, tp...), append(sub, ts...)
|
||||
}
|
||||
@@ -217,6 +217,15 @@ type Principal struct {
|
||||
// ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes
|
||||
// itself. Set while the vault's holder serves it.
|
||||
AsksItsOwnSecrets bool
|
||||
// AsksTheVaultToSign is a machine principal whose node-engine may ask the vault's `sign` in its own name, read the
|
||||
// vault's public signing halves (`signers`) and the controller's lease key (novox/hq ADR 0550 §2): its own node's
|
||||
// certificate, the keys it trusts, and the lease write that drops the controller's key. Set while the vault's holder
|
||||
// serves `sign`.
|
||||
AsksTheVaultToSign bool
|
||||
// ServedReads are, for a machine principal, the keys of mesh-config's resolved values that other nodes' modules serve
|
||||
// from (`<node>.<module>.<key>`, catalogue.ServedSettings): what every consumer is told, and so what a node composing
|
||||
// its own modules reads of a provider elsewhere (issue 1126), each key alone. Sorted, none twice.
|
||||
ServedReads []string
|
||||
// FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants
|
||||
// part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows).
|
||||
FollowsGrantsUserList bool
|
||||
@@ -228,6 +237,10 @@ type Principal struct {
|
||||
// by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its
|
||||
// manifest, not at the controller's next start (issue 1790).
|
||||
CreatesStreams []string
|
||||
// CreatesConsumers are the durable consumers the grants part's principal alone may create beside the controller
|
||||
// (novox/hq ADR 0561 §6): each placed module's on EVENTS and each process's on its work queue (AccountConsumers), by
|
||||
// stream and name. Created only, never updated or deleted.
|
||||
CreatesConsumers []ModuleConsumer
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||
@@ -352,6 +365,10 @@ var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"}
|
||||
// key (issue 1126, ADR 0550 §5); granted as Principal.AsksItsOwnSecrets says.
|
||||
var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"}
|
||||
|
||||
// VaultSign is the vault's verb a machine's node-engine asks in its own name for its own node's certificate (novox/hq
|
||||
// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says.
|
||||
var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
|
||||
@@ -898,6 +915,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// manifests, the builder's builds (the manifest of a build its want names), and mesh-config's resolved values of
|
||||
// its own node alone. Read only.
|
||||
pub = append(pub, NodeComposeReads(p.Node)...)
|
||||
// And, of the other nodes' resolved values, the keys their modules serve from (issue 1126), each one key.
|
||||
served, err := ServedReadGrants(p.ServedReads)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, served...)
|
||||
// And an object of the declaration bucket by its name, and nothing of the bucket's list or writing
|
||||
// (novox/hq ADR 0486 §2, issue 1697).
|
||||
fileReads, err := DeclarationFilesReads(p.Node)
|
||||
@@ -939,6 +962,18 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// on it rather than at the next unrelated change (ADR 0547 §2).
|
||||
sub = append(sub, VaultSecretChanged)
|
||||
}
|
||||
// And, while the vault serves `sign`, its own node's certificate in its own name, the vault's public signing
|
||||
// halves, read and watched, and the controller's lease key, read and watched, which says when the controller's key
|
||||
// is no longer trusted (ADR 0550 §2). Read only.
|
||||
if p.AsksTheVaultToSign {
|
||||
pub = append(pub, VaultSignCall(p))
|
||||
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
|
||||
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
|
||||
}
|
||||
// And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3).
|
||||
ep, es := engineGrants(p)
|
||||
pub = append(pub, ep...)
|
||||
sub = append(sub, es...)
|
||||
// And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR
|
||||
// 0547 clause 7): one key, and an object by its name.
|
||||
if p.FollowsGrantsUserList {
|
||||
@@ -959,6 +994,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// And creating each stream a manifest declares, by its own name (the operator, 2026-10-26 08:54; issue
|
||||
// 1790): the create and the read of that one stream, never an update, a delete or a stream not declared.
|
||||
pub = append(pub, CreateStreamGrants(p.CreatesStreams)...)
|
||||
// And creating each durable consumer an account needs, by its stream and name (ADR 0561 §6).
|
||||
pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...)
|
||||
// And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7).
|
||||
pub = append(pub, GrantsUserListWrites()...)
|
||||
}
|
||||
@@ -1646,6 +1683,21 @@ func CreateStreamGrants(streams []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// CreateConsumerGrants are the subjects that create each consumer by its stream and name, and read its info back: the
|
||||
// create of the client library's CreateConsumer for a consumer with no filter or several, never one filter's form (a
|
||||
// wildcard in a filter would widen the grant), an update or a delete.
|
||||
func CreateConsumerGrants(consumers []ModuleConsumer) []string {
|
||||
var out []string
|
||||
for _, mc := range consumers {
|
||||
c := mc.Consumer
|
||||
if !safeSubject.MatchString(c.Stream) || !safeSubject.MatchString(c.Name) || len(c.Filters) == 1 {
|
||||
continue
|
||||
}
|
||||
out = append(out, "$JS.API.CONSUMER.CREATE."+c.Stream+"."+c.Name, "$JS.API.CONSUMER.INFO."+c.Stream+"."+c.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants
|
||||
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
|
||||
const GrantsModule = "mesh-grants"
|
||||
@@ -1814,6 +1866,11 @@ func VaultOwnCall(p Principal) string {
|
||||
return "mesh.seat." + VaultOwn.Seat + "." + CallKind + "." + VaultOwn.Verb + "." + CallerToken(p.Username())
|
||||
}
|
||||
|
||||
// VaultSignCall is the subject a machine principal's node-engine asks the vault's `sign` on, in its own name.
|
||||
func VaultSignCall(p Principal) string {
|
||||
return "mesh.seat." + VaultSign.Seat + "." + CallKind + "." + VaultSign.Verb + "." + CallerToken(p.Username())
|
||||
}
|
||||
|
||||
// VaultSecretChanged is the vault's event that a secret changed (a name and its generation, never a value), which a
|
||||
// node composing its own hears to compose again (issue 1126, ADR 0547 §2).
|
||||
var VaultSecretChanged = "mesh.mod." + VaultOwn.Seat + ".event.secret-changed"
|
||||
|
||||
+32
-3
@@ -312,13 +312,42 @@ func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err
|
||||
const ResolvedBucket = "mesh-config_resolved"
|
||||
|
||||
// NodeComposeReads is what a node's agent reads to compose its own modules (novox/hq ADR 0547 §2, ADR 0549 §3, issue
|
||||
// 1126): every key of the indexer's placement, modules and manifests and of the builder's builds, and of mesh-config's
|
||||
// resolved values only its own node's, `<node>.>`. Reads only; the node is checked as every subject token is by the
|
||||
// 1126): every key of the indexer's placement, placement candidates, modules and manifests and of the builder's builds,
|
||||
// and of mesh-config's resolved values only its own node's, `<node>.>`. Every candidate key, not only `*.<node>`: a node
|
||||
// answering its candidate composes with the whole group's placement (the seed places every node at once, and a binding
|
||||
// crosses nodes), and nothing in a placement is secret. Reads only; the node is checked as every subject token is by the
|
||||
// caller's principal.
|
||||
func NodeComposeReads(node string) []string {
|
||||
var out []string
|
||||
for _, bucket := range []string{delivery.PlacementBucket, delivery.ModulesBucket, delivery.ManifestsBucket, delivery.BuilderBuildsBucket} {
|
||||
for _, bucket := range []string{delivery.PlacementBucket, delivery.PlacementCandidatesBucket, delivery.ModulesBucket,
|
||||
delivery.ManifestsBucket, delivery.BuilderBuildsBucket} {
|
||||
out = append(out, readGrants(bucket, "")...)
|
||||
}
|
||||
return append(out, readGrants(ResolvedBucket, node+".>")...)
|
||||
}
|
||||
|
||||
// ServedReadGrants is the read of each key of mesh-config's resolved values named, `<node>.<module>.<key>`
|
||||
// (Principal.ServedReads, issue 1126): a get and a watch of that key alone. Each part is checked as every subject token
|
||||
// is, so a key cannot widen the grant.
|
||||
func ServedReadGrants(keys []string) ([]string, error) {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, k := range keys {
|
||||
parts := strings.Split(k, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("%q is not <node>.<module>.<key>", k)
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !safeSubject.MatchString(part) {
|
||||
return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", part)
|
||||
}
|
||||
}
|
||||
for _, g := range readGrants(ResolvedBucket, k) {
|
||||
if !seen[g] {
|
||||
seen[g] = true
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"how": "Written by mesh-controller 0fbb5ef's own issueWith, sealProvisionerClient and heldProcessAccount (cmd/mesh-controller), run unchanged in a scratch integration test against a local store with the default seats seeded, on 2026-10-27 (issue 774): each row is the exact value the controller handed the vault. Never edit a row by hand: capture it again.",
|
||||
"rows": [
|
||||
{
|
||||
"name": "module",
|
||||
"held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"module\":\"cap-bot\",\"user\":\"laptop.cap-bot\",\"password\":\"pw-module\",\"claims\":[{\"seat\":\"mesh-vault\",\"scope\":\"mesh\",\"serves\":[\"get\",\"accept\",\"secret-ask\",\"issued\",\"rotate\",\"move-in\",\"node-key\",\"own\",\"move-asks\"]},{\"seat\":\"node-service-manager\",\"scope\":\"node\",\"serves\":[\"status\"]},{\"seat\":\"mesh-controller\",\"scope\":\"mesh\",\"serves\":[\"tools\",\"calls\",\"status\",\"nodes\",\"node\",\"placement-export\",\"modules\",\"seats\",\"builds\",\"plans\",\"delivery-plan\",\"delivery-order\",\"delivery-check\",\"deliver\",\"delivery-stop\",\"delivery-backups-again\",\"delivery-walks\",\"plan\",\"assign\",\"unassign\",\"pin\",\"unpin\",\"push\",\"rotate\",\"give\",\"issued\",\"secret-move\",\"secret-ask\",\"issue\",\"token\",\"settings\",\"command\",\"queue\",\"cancel\",\"clear\",\"replay\",\"try\",\"kill\",\"pause\",\"resume\",\"hand-act\",\"drill\",\"warranted\",\"hand-acts\",\"durations\",\"conditions\",\"healers\",\"doctor\",\"upgrade\",\"bus\",\"retire\",\"cleanup\",\"judge-directories\",\"root-free\",\"dead-letters\",\"data\",\"build\",\"artifacts\",\"held\",\"collect\",\"move-placement\",\"move-config\",\"mirrors\",\"images\"]},{\"seat\":\"no-such-seat\",\"scope\":\"node\"}]}"
|
||||
},
|
||||
{
|
||||
"name": "module-no-claims-no-fingerprint",
|
||||
"held": "{\"url\":\"nats://10.0.0.1:4222\",\"node\":\"laptop\",\"module\":\"cap-plain\",\"user\":\"laptop.cap-plain\",\"password\":\"pw-plain\"}"
|
||||
},
|
||||
{
|
||||
"name": "provisioner",
|
||||
"held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"user\":\"provisioner.laptop\",\"password\":\"pw-prov\"}"
|
||||
},
|
||||
{
|
||||
"name": "process",
|
||||
"held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"module\":\"mesh-mail\",\"user\":\"laptop.mesh-mail.send\",\"password\":\"pw-proc\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -77,8 +77,13 @@ func grantsOwnObjects(p Principal, pattern string) bool {
|
||||
// readers too (announce.State.Readers); both are honoured.
|
||||
const VaultBusAccounts = "mesh-vault.bus-accounts"
|
||||
|
||||
// NamedReaders are the states only the modules named may read, by `<owner>.<name>`.
|
||||
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}}
|
||||
// NamedReaders are the states only the modules named may read, by `<owner>.<name>`: the grants part's wanted accounts
|
||||
// are read by the vault alone (novox/hq ADR 0561 §2).
|
||||
// The controller's lease is read by the parts that turn on at its write (ADR 0561 §3, §6), and by the assigner, which
|
||||
// from then reads the catalogue's placement alone (ADR 0549 §5) and the grants from the grants part (ADR 0547 §4, §5;
|
||||
// delivery.GrantSource) (issue 774).
|
||||
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat},
|
||||
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule}}
|
||||
|
||||
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
||||
// that names none.
|
||||
|
||||
+57
-2
@@ -5,6 +5,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Every user the composed file should contain, derived from what the mesh knows.
|
||||
@@ -25,6 +26,9 @@ type Declared struct {
|
||||
Emits []string
|
||||
Consumes []string
|
||||
Serves []string
|
||||
// Served are the config keys its served values and zone name fill from its settings (catalogue.ServedSettings): what
|
||||
// every node may read of its resolved values where it runs (Principal.ServedReads, issue 1126).
|
||||
Served []string
|
||||
// Holds are the seats this module claims, with the protocol each seat declares. A seat the
|
||||
// mesh defines for itself declares no protocol, so holding one grants nothing on the bus —
|
||||
// which is right: those seats are about who does a job, not about who may say what.
|
||||
@@ -130,6 +134,11 @@ type Records struct {
|
||||
// account agents run as, judged unable to become root by its node-engine, and serves no login shell
|
||||
// execute. A machine absent is not free: no record of a pass is no pass.
|
||||
RootFree map[string]bool
|
||||
// Engine is the node-engine's own declaration, from its manifest (EngineModule): placed on every node though no node is
|
||||
// assigned it, since every node runs it (novox/hq ADR 0563 §2, §3). Its state, logs and streams are created by the
|
||||
// grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account.
|
||||
// Nil where its manifest was not read.
|
||||
Engine *Declared
|
||||
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
|
||||
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
|
||||
Announced map[string]announce.States
|
||||
@@ -144,6 +153,8 @@ func Users(r Records) ([]Principal, error) {
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
|
||||
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
|
||||
|
||||
served := servedReadsOf(r)
|
||||
grants := -1
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
var checks []string
|
||||
@@ -159,8 +170,9 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
||||
AsksItsOwnSecrets: servesVerb(r, VaultOwn),
|
||||
FollowsGrantsUserList: bus})
|
||||
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign),
|
||||
Uses: asksSeatOf(r),
|
||||
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)})
|
||||
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
|
||||
// per machine that runs it, delivered to the module running it.
|
||||
for _, d := range r.Assigned[node] {
|
||||
@@ -220,6 +232,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
grants = len(out) - 1
|
||||
}
|
||||
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
|
||||
// account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it.
|
||||
@@ -253,6 +266,11 @@ func Users(r Records) ([]Principal, error) {
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
|
||||
if grants >= 0 {
|
||||
out[grants].CreatesConsumers = AccountConsumers(out)
|
||||
}
|
||||
|
||||
// Refused here rather than discovered by the server. Two users with one name is a file the
|
||||
// server reads as one of them, and which one depends on the order — so a module assigned to a
|
||||
// node twice, or a person named after nothing, is a composition that must not be written.
|
||||
@@ -439,7 +457,15 @@ func runnerServes(d Declared) bool {
|
||||
// name. What the grants part may create, and nothing beside.
|
||||
func declaredStreamsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
placed := [][]Declared{}
|
||||
for _, mods := range r.Assigned {
|
||||
placed = append(placed, mods)
|
||||
}
|
||||
// The node-engine's manifest is placed on every node (ADR 0563): its declared state is created as a placed module's.
|
||||
if r.Engine != nil {
|
||||
placed = append(placed, []Declared{*r.Engine})
|
||||
}
|
||||
for _, mods := range placed {
|
||||
for _, d := range mods {
|
||||
for _, b := range d.State {
|
||||
seen["KV_"+b.Bucket()] = true
|
||||
@@ -459,3 +485,32 @@ func declaredStreamsOf(r Records) []string {
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// servedReadsOf is every key of mesh-config's resolved values a module assigned on a node serves from,
|
||||
// `<node>.<module>.<key>`, sorted: each part a subject token, so a key that cannot be one is granted nothing (and the
|
||||
// node composing a consumer of it refuses, naming it), never a wider read.
|
||||
func servedReadsOf(r Records) []string {
|
||||
var out []string
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, k := range d.Served {
|
||||
if safeSubject.MatchString(node) && safeSubject.MatchString(d.Module) && safeSubject.MatchString(k) {
|
||||
out = append(out, node+"."+d.Module+"."+k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
// servedOn is, of the served keys, those of other nodes than node: its own slice it reads whole.
|
||||
func servedOn(served []string, node string) []string {
|
||||
var out []string
|
||||
for _, k := range served {
|
||||
if !strings.HasPrefix(k, node+".") {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -245,6 +245,11 @@ var WritersTable = []WriterRow{
|
||||
{State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `<node>.>`",
|
||||
KeptIn: "key-value " + delivery.HaveBucket + ", twenty revisions of each key",
|
||||
Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys},
|
||||
// The acts a node's engine was asked through its node seat, and its own asks (novox/hq ADR 0563 §2, §3): the
|
||||
// node-engine's declared state, created by the grants part, written by each node's engine under its own keys.
|
||||
{State: "a machine's hand acts", Writer: "the node-engine of the machine, under its own keys `<node>.>`, compare-and-set",
|
||||
KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts",
|
||||
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), Writes: ownNodeKeys},
|
||||
// The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each
|
||||
// object under the digest of its content; a node-engine reads one by its name, with no list and no write.
|
||||
{State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " +
|
||||
|
||||
@@ -75,6 +75,21 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) {
|
||||
if got != "shanks.node-tools" {
|
||||
t.Fatalf("with a caller in the arguments the tool read %q, want the runtime's", got)
|
||||
}
|
||||
// A module's trusted list of callers (issue 774, ADR 0563 §8) grants the callers it names as the runtime proved them:
|
||||
// a module on any machine, <machine>.<module> exactly, person.<name> exactly; no wildcard, never one unproven.
|
||||
callers, err := ParseCallers([]byte(`{"callers":["node-tools","ace.claude-code","person.jo","*"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for caller, want := range map[string]bool{"shanks.node-tools": true, "ace.claude-code": true, "g14.claude-code": false,
|
||||
"person.jo": true, "person.node-tools": false, Unproven: false, "": false, "novox.mesh-vault": false} {
|
||||
if callers.Allows(caller) != want {
|
||||
t.Errorf("%q allowed: %v, want %v", caller, !want, want)
|
||||
}
|
||||
}
|
||||
if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil {
|
||||
t.Error("a list with a field it does not know was read")
|
||||
}
|
||||
}
|
||||
|
||||
// A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Callers is a module's trusted list of who may call a verb of its (novox/hq ADR 0459 §2, ADR 0563 §8), as the mesh
|
||||
// writes a list setting into a file the module reads: `{"callers": [...]}`. An entry is a module's name (that module on
|
||||
// any machine), exactly <machine>.<module>, or exactly person.<name>. Each caller is the runtime's proof (CallerOf), never
|
||||
// an argument; nobody beyond the list is granted, a wildcard is no entry, and an unproven caller never matches.
|
||||
type Callers []string
|
||||
|
||||
// ParseCallers reads a callers file: the list under `callers`, and nothing else.
|
||||
func ParseCallers(raw []byte) (Callers, error) {
|
||||
var f struct {
|
||||
Callers Callers `json:"callers"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return nil, fmt.Errorf("the callers setting is not a list of callers: %v", err)
|
||||
}
|
||||
return f.Callers, nil
|
||||
}
|
||||
|
||||
// Allows says whether caller, as the runtime proved it, is one the list names.
|
||||
func (c Callers) Allows(caller string) bool {
|
||||
if caller == "" || caller == Unproven {
|
||||
return false
|
||||
}
|
||||
for _, entry := range c {
|
||||
if entry == "" || strings.ContainsAny(entry, "*> ") {
|
||||
continue
|
||||
}
|
||||
if entry == caller {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(entry, ".") {
|
||||
if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package catalogue
|
||||
|
||||
// The delivery's checker (novox/hq ADR 0547 §10, issue 774): one holder for the mesh, the module
|
||||
// mesh-delivery-checker, which judges a pull request's head and a pushed head the same way. The try the
|
||||
// controller forwarded to it is a verb of this seat, answered by the checker with the verdict it gives a pull
|
||||
// request, so the try outlives the controller.
|
||||
|
||||
// CheckerSeat is the seat mesh-delivery-checker holds.
|
||||
const CheckerSeat = "mesh-delivery-checker"
|
||||
|
||||
// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them
|
||||
// everywhere.
|
||||
func checkerVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
|
||||
"judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " +
|
||||
"levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " +
|
||||
"reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " +
|
||||
"4 wait or run at once, mesh-wide.",
|
||||
Input: schema(map[string]string{"repository": "<owner>/<name>",
|
||||
"head": "the full commit to try, 40 or 64 hex digits",
|
||||
"trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent",
|
||||
"tests": "a go test -run pattern: only those tests run, and nothing is kept",
|
||||
"level": "unit or integration: only this level; both when absent"},
|
||||
[]string{"repository", "head"}),
|
||||
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
|
||||
{Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " +
|
||||
"(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " +
|
||||
"it was merged onto, the time it took and what failed first. Read-only.",
|
||||
Input: schema(map[string]string{"id": "the try's id, try-<digits>"}, []string{"id"})},
|
||||
{Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " +
|
||||
"what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " +
|
||||
"id. A try that ended already is refused.",
|
||||
Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"},
|
||||
[]string{"id", "why"})},
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,19 @@ func deliveryVerbs() []Verb {
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
||||
[]string{"id", "why"})},
|
||||
deliveryRemovedInR6("retire-history"),
|
||||
{Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " +
|
||||
"trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " +
|
||||
"The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " +
|
||||
"is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " +
|
||||
"reason, naming why.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
|
||||
"properties": map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
|
||||
"description": "the held deliveries' ids (owner/repository@commit)"},
|
||||
"why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"},
|
||||
}},
|
||||
// Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere.
|
||||
Optional: true},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package catalogue
|
||||
|
||||
// The node seat node-engine (novox/hq ADR 0563 §1): held on every node by that node's engine (mesh-host), which serves
|
||||
// its verbs on its own bus account. No module claims it and no assignment holds it: what the mesh knows of its holder
|
||||
// is the node's record and the engine's haves (ADR 0563 §1 changes ADR 0110 §6 and §7 for this one seat).
|
||||
//
|
||||
// Its verbs carry no signature (§7): the runtime proves the caller, which matters only for the record. The read-only
|
||||
// ones change nothing or are bounded in the engine's own state (§2); hand-over and access-act perform nothing on the
|
||||
// call, and act only on the operator's warrant to the engine's own ask (§3).
|
||||
|
||||
// EngineSeat is the node seat every node's engine holds.
|
||||
const EngineSeat = "node-engine"
|
||||
|
||||
// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them.
|
||||
func EngineVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "hand-over", Description: "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, " +
|
||||
"§5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, " +
|
||||
"reached through no link) and refuses in words, or asks the operator on their channel at the level approve, " +
|
||||
"binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands " +
|
||||
"it over. One ask is open per path; acts shows where it stands.",
|
||||
Input: schema(map[string]string{"path": "the directory's absolute path, as the engine states it",
|
||||
"why": "why it is asked, in words the operator reads"}, []string{"path"})},
|
||||
{Name: "access-act", Description: "Ask the operator for the take act on a library whose root holds data outside its " +
|
||||
"group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its " +
|
||||
"root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes " +
|
||||
"sets the library's group, group write and setgid under its root. One ask is open per access.",
|
||||
Input: schema(map[string]string{"act": "the act: take", "access": "the access, library:<name>",
|
||||
"why": "why it is asked, in words the operator reads"}, []string{"act", "access"})},
|
||||
{Name: "judge-directories", Description: "Which of this machine's declared directories the engine would refuse to " +
|
||||
"make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "setuid-search", Description: "Throw away the engine's last search for setuid programs and start a full one " +
|
||||
"(novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while " +
|
||||
"one runs, and when one was started within the hour, naming when the next may start.",
|
||||
Input: schema(map[string]string{"why": "why it is asked, in words"}, nil)},
|
||||
{Name: "root-free", Description: "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only " +
|
||||
"on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root " +
|
||||
"without a person, and the login shell's execute not served here. Anything else is no pass, and says why. " +
|
||||
"Answered by this machine's engine alone.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "acts", Description: "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): " +
|
||||
"each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " +
|
||||
"operator's warrant. Read-only.",
|
||||
Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime
|
||||
// proved it and the ask (ADR 0563 §1).
|
||||
var EngineEvents = []string{"asked", "done", "refused"}
|
||||
|
||||
// engineSeat is the seat's row in the closed set.
|
||||
func engineSeat() Seat {
|
||||
return Seat{Name: EngineSeat, Scope: ScopeNode, Serves: EngineVerbs(), Emits: append([]string(nil), EngineEvents...),
|
||||
HeldByTheEngine: true, Decision: "novox/hq ADR 0563"}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package catalogue
|
||||
|
||||
import "strings"
|
||||
|
||||
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
|
||||
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
|
||||
const LauncherSuffix = ".serve.mjs"
|
||||
|
||||
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root: for TypeScript
|
||||
// every entrypoint `x.js` is started by `x.serve.mjs` beside it; an entrypoint that is not JavaScript and every other
|
||||
// language, whose build is already executable, get none. The one rule mesh-build-node, build-agent, the controller and
|
||||
// a node taking in a build share (R4e, issue 1126; buildstep.Launchers says it for the build modules), so the name a
|
||||
// build writes is the name a reader expects.
|
||||
func Launchers(language string, entrypoints []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
if language != "typescript" {
|
||||
return out
|
||||
}
|
||||
for _, entry := range entrypoints {
|
||||
if strings.HasSuffix(entry, ".js") {
|
||||
out[entry] = strings.TrimSuffix(entry, ".js") + LauncherSuffix
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+41
-2
@@ -67,6 +67,10 @@ type Seat struct {
|
||||
// names paths. Compiled, never stored, like Receives: adding or taking away a path is a decision,
|
||||
// recorded, and the store's rows carry no column for it.
|
||||
Paths []SeatPath
|
||||
// HeldByTheEngine says the seat is held on every node by that node's engine, which serves its verbs on its own bus
|
||||
// account (novox/hq ADR 0563 §1): no module claims it and no assignment holds it. Compiled, never stored, like
|
||||
// Receives.
|
||||
HeldByTheEngine bool
|
||||
// Decision is the record that made it a seat.
|
||||
Decision string
|
||||
}
|
||||
@@ -171,7 +175,8 @@ var defaultSeats = append([]Seat{
|
||||
"origin": "issued for a bus account the controller minted for a core or moved module and hands " +
|
||||
"at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)},
|
||||
{Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " +
|
||||
"from the controller alone: the ask is recorded in the vault's store and bounded before the prompt " +
|
||||
"from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR " +
|
||||
"0563 §8): the ask is recorded in the vault's store and bounded before the prompt " +
|
||||
"opens, and what is typed is sealed to a key the vault made for the call and opens there alone.",
|
||||
Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for",
|
||||
"module": "the module", "name": "the secret's name in the module",
|
||||
@@ -209,11 +214,33 @@ var defaultSeats = append([]Seat{
|
||||
"first or changed key is recorded and the machine's core secrets are said changed; the same key " +
|
||||
"again changes nothing.",
|
||||
Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)},
|
||||
// A machine's node-engine composes its own modules and asks their secrets sealed to its key (novox/hq ADR 0547
|
||||
// §2, ADR 0550 §5, issue 1126); granted as buscompose's Principal.AsksItsOwnSecrets says.
|
||||
{Name: "own", Optional: true, Description: "Answer the node-engine of one machine, about its own machine " +
|
||||
"alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair " +
|
||||
"credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A " +
|
||||
"credential the vault does not hold is named missing; never a value in the clear.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"credentials": map[string]any{
|
||||
"type": "array", "items": map[string]any{"type": "object"},
|
||||
"description": "the pair credentials the machine's composition reads, each {role: consumer|provider, " +
|
||||
"provision, consumer, consumer_module, provider, local}"}}}},
|
||||
{Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " +
|
||||
"into the vault's, as they are and once, from the controller alone. No value is in any of them.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{
|
||||
"type": "array", "items": map[string]any{"type": "object"},
|
||||
"description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}},
|
||||
{Name: "issue-account", Optional: true, Description: "Mint one bus account the grants part wants, now (novox/hq " +
|
||||
"ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the " +
|
||||
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
|
||||
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
|
||||
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
|
||||
"kind node-certificate signs the certificate of the calling machine's internal name for the serving key " +
|
||||
"its record states, built from bus state alone and asked by that machine's node-engine only; kind " +
|
||||
"enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " +
|
||||
"Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.",
|
||||
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
|
||||
"node": "the machine, for a node certificate: the caller's own",
|
||||
"reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})},
|
||||
}},
|
||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||||
@@ -248,6 +275,8 @@ var defaultSeats = append([]Seat{
|
||||
// machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act.
|
||||
// What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it.
|
||||
{Name: DeliverySeat, Scope: ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"},
|
||||
// The delivery's checker (novox/hq ADR 0547 §10, issue 774): the try is its verb, not the controller's.
|
||||
{Name: CheckerSeat, Scope: ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"},
|
||||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
||||
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
||||
// They keep their names until that migration is done deliberately, apart from the node-* pass.
|
||||
@@ -429,6 +458,8 @@ var defaultSeats = append([]Seat{
|
||||
// A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned
|
||||
// on every node, then node-reads goes (ADR 0394 §9).
|
||||
{Name: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()},
|
||||
// The node's engine's own verbs and asks (novox/hq ADR 0563 §1): held by the engine on every node, by no module.
|
||||
engineSeat(),
|
||||
},
|
||||
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
||||
graphicalSessionSeats()...)
|
||||
@@ -502,7 +533,8 @@ func UseSeats(s []Seat) {
|
||||
row.Paths = d.Paths
|
||||
row.Replicated = d.Replicated
|
||||
row.PulledByKind = d.PulledByKind
|
||||
row.Yields = d.Yields // never stored (ADR 0553)
|
||||
row.Yields = d.Yields // never stored (ADR 0553)
|
||||
row.HeldByTheEngine = d.HeldByTheEngine // never stored (ADR 0563)
|
||||
row.Serves = optionalAsCompiled(row.Serves, d.Serves)
|
||||
}
|
||||
merged = append(merged, row)
|
||||
@@ -615,6 +647,13 @@ func claimProblems(m Manifest) []string {
|
||||
}
|
||||
|
||||
for _, c := range m.Claims {
|
||||
if s, known := SeatNamed(c.Name); known && s.HeldByTheEngine {
|
||||
// The node's engine holds it on every node, and no module may (novox/hq ADR 0563 §1): a module answering
|
||||
// root-free or a hand-over would be an agent answering it.
|
||||
problems = append(problems, fmt.Sprintf("%s claims %s, which every node's engine holds itself and no "+
|
||||
"module may (novox/hq ADR 0563 §1)", m.Module, c.Name))
|
||||
continue
|
||||
}
|
||||
if _, known := SeatNamed(c.Name); known {
|
||||
// **A seat's scope and what it delivers are not judged here** (novox/hq ADR 0122).
|
||||
// This function runs wherever a manifest is parsed, and one of those places is the
|
||||
|
||||
@@ -43,6 +43,43 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// The checker's seat serves the try, which replaces the shell's merge check (ADR 0547 §10, ADR 0245).
|
||||
var checkerVerbs []string
|
||||
for _, s := range Seats() {
|
||||
if s.Name == CheckerSeat {
|
||||
for _, v := range s.Serves {
|
||||
checkerVerbs = append(checkerVerbs, v.Name)
|
||||
if v.Name == "try" && strings.Join(v.Replaces, "|") != "sh merge-check.sh|docker run postgres" {
|
||||
t.Errorf("try replaces %v", v.Replaces)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" {
|
||||
t.Errorf("the checker seat serves %v", checkerVerbs)
|
||||
}
|
||||
// The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its six
|
||||
// verbs and three events, held by no assignment, and a module's claim of it refused.
|
||||
if engine, ok := SeatNamed(EngineSeat); !ok || engine.Scope != ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine {
|
||||
t.Errorf("node-engine is %+v, not a node seat delivering nothing that the engine holds", engine)
|
||||
} else {
|
||||
var verbs []string
|
||||
for _, v := range engine.Serves {
|
||||
verbs = append(verbs, v.Name)
|
||||
}
|
||||
// acts reads one act by its id too (ADR 0563 §9: the seat's tools are its command line).
|
||||
if props, _ := engine.Serves[len(engine.Serves)-1].Input["properties"].(map[string]any); props["id"] == nil {
|
||||
t.Errorf("acts takes no id: %v", engine.Serves[len(engine.Serves)-1].Input)
|
||||
}
|
||||
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts" ||
|
||||
strings.Join(engine.Emits, ",") != "asked,done,refused" {
|
||||
t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"thing","version":"1","claims":[{"name":"node-engine","scope":"node"}]}`)); err == nil ||
|
||||
!strings.Contains(err.Error(), "node-engine") {
|
||||
t.Errorf("a module's claim of node-engine was not refused: %v", err)
|
||||
}
|
||||
// Forty-six with the three build seats replacing node-build-agent and the retired mesh-build-machine (novox/hq
|
||||
// ADR 0525 §2, §10); forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes.
|
||||
// Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
@@ -56,8 +93,9 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
if len(Seats()) != 46 {
|
||||
t.Errorf("the mesh defines %d seats rather than 46; the set is closed, so a change here is "+
|
||||
// Forty-seven with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-eight with node-engine (novox/hq ADR 0563 §1).
|
||||
if len(Seats()) != 48 {
|
||||
t.Errorf("the mesh defines %d seats rather than 48; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,30 @@ func Settle(base map[string]any, layers []Layer) (map[string]any, error) {
|
||||
return filledFromSettings(deepCopy(base), layers, "what is served")
|
||||
}
|
||||
|
||||
// ServedSettings is every key a module's served values and its zone's name fill from its settings (`${setting:<key>}`
|
||||
// in a top-level string, as Settle fills them), sorted: what every consumer of the module is told, wherever it runs,
|
||||
// and so the one part of a module's config values on its node that a node composing a consumer elsewhere reads (issue
|
||||
// 1126). None for a module that serves only literals.
|
||||
func ServedSettings(m Manifest) []string {
|
||||
seen := map[string]bool{}
|
||||
note := func(v any) {
|
||||
if s, ok := v.(string); ok {
|
||||
for _, k := range settingsUsed(s) {
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, values := range m.Serves {
|
||||
for _, v := range values {
|
||||
note(v)
|
||||
}
|
||||
}
|
||||
if m.Zone != nil {
|
||||
note(m.Zone.Name)
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// overridden lays settings over a contribution: a map whose keys are its contract. Only the keys the
|
||||
// map already declares are touched; the rest of a layer is somebody else's business (a file's,
|
||||
// another destination's) and is left to reach it there.
|
||||
|
||||
@@ -121,6 +121,8 @@ func TestTheVaultsSeatPromisesTheMove(t *testing.T) {
|
||||
"move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin",
|
||||
"generation", "made_at", "call", "sealed", "read_back_to"},
|
||||
"move-asks": {"asks"},
|
||||
// And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key.
|
||||
"own": {"credentials"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
@@ -148,7 +150,7 @@ func TestTheVaultsSeatPromisesTheMove(t *testing.T) {
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "move-asks"}}}}
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "node-key", "move-asks", "own"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving the move was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestTheDeliverySeatHasNoPlanVerbs(t *testing.T) {
|
||||
if _, ok := rows["mark-ready"]; ok {
|
||||
t.Errorf("mark-ready is still promised")
|
||||
}
|
||||
for _, kept := range []string{"deliveries", "show", "groups", "checks", "table", "recheck", "close", "stalled"} {
|
||||
for _, kept := range []string{"deliveries", "show", "groups", "checks", "table", "recheck", "close", "stalled", "settle"} {
|
||||
if v, ok := rows[kept]; !ok || strings.HasPrefix(v.Description, "Removed") {
|
||||
t.Errorf("%s is not kept (ADR 0539 clause 17)", kept)
|
||||
}
|
||||
|
||||
+219
-30
@@ -4,21 +4,28 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as
|
||||
// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
|
||||
// (`<node>.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads.
|
||||
// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
|
||||
// (mesh-hand-compose) gets that one. Each module's Data says whether its manifest declares data. The compositions are not
|
||||
// (mesh-hand-compose) gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
|
||||
// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own,
|
||||
// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed
|
||||
// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are not
|
||||
// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest).
|
||||
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) {
|
||||
return compositions(in, node, fps, mode, false)
|
||||
out, skipped, _, err = compositions(in, node, fps, mode, false)
|
||||
return out, skipped, err
|
||||
}
|
||||
|
||||
// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's
|
||||
@@ -34,50 +41,104 @@ const BusUsersStandIn = "the bus's user list"
|
||||
// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list
|
||||
// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself.
|
||||
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) {
|
||||
return compositions(in, node, fps, Reading, true)
|
||||
out, skipped, _, err := compositions(in, node, fps, Reading, true)
|
||||
return out, skipped, err
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped []string, err error) {
|
||||
// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a
|
||||
// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
|
||||
// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted,
|
||||
// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a
|
||||
// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes
|
||||
// as CompositionsApplied composes it: the modes differ only in an own secret never issued.
|
||||
func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
return compositions(in, node, fps, Foreseeing, false)
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
reading := in
|
||||
reading.Nodes = append([]Node(nil), in.Nodes...)
|
||||
found := false
|
||||
inOwn := map[string]bool{}
|
||||
for i := range reading.Nodes {
|
||||
if reading.Nodes[i].Name == node {
|
||||
reading.Nodes[i].ReadsModules = true
|
||||
found = true
|
||||
for m, ok := range reading.Nodes[i].InOwn {
|
||||
inOwn[m] = ok
|
||||
}
|
||||
}
|
||||
}
|
||||
// The private network's module is built by nobody: no fingerprint of the new form ever names it.
|
||||
inOwn[overlay.Name] = true
|
||||
folds := func(module string) bool {
|
||||
return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2)
|
||||
}
|
||||
if !found {
|
||||
return nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
|
||||
return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
|
||||
}
|
||||
// A folded module built by a build of the old form (Node.RunsOwn) is left to the node: composed as a stub, so what
|
||||
// it provides and requires still shapes the rest, and its resources are then the ones the node runs.
|
||||
carried := map[string]bool{}
|
||||
var runsOwn *delivery.Composed
|
||||
for i := range reading.Nodes {
|
||||
n := &reading.Nodes[i]
|
||||
if n.Name != node || n.RunsOwn == nil {
|
||||
continue
|
||||
}
|
||||
runsOwn = n.RunsOwn
|
||||
for m := range inOwn {
|
||||
if !folds(m) {
|
||||
continue
|
||||
}
|
||||
man, ok := n.Kept[m]
|
||||
if !ok {
|
||||
man, ok = reading.Catalogue[m]
|
||||
}
|
||||
if !ok || !NamesBuild(man) {
|
||||
continue
|
||||
}
|
||||
carried[m] = true
|
||||
kept := make(map[string]catalogue.Manifest, len(n.Kept)+1)
|
||||
for k, v := range n.Kept {
|
||||
kept[k] = v
|
||||
}
|
||||
kept[m] = Stub(man)
|
||||
n.Kept = kept
|
||||
}
|
||||
}
|
||||
isCarried := func(module, id string) bool {
|
||||
if module == "" {
|
||||
module, _ = declaration.ModuleGuess(id)
|
||||
}
|
||||
return carried[module]
|
||||
}
|
||||
d, problems, err := ComposeIn(reading, node, mode)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
|
||||
return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
|
||||
}
|
||||
if applied {
|
||||
var stood []string
|
||||
for _, x := range d.StoodIn {
|
||||
// The bus's user list is the bus node's own to follow, written alone and never from a composition the node
|
||||
// made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied.
|
||||
if x != BusUsersStandIn {
|
||||
stood = append(stood, x)
|
||||
}
|
||||
}
|
||||
stood = append(stood, d.Foreseen...)
|
||||
for _, p := range d.ForeseenPorts {
|
||||
stood = append(stood, "port "+p)
|
||||
}
|
||||
if len(stood) > 0 {
|
||||
sort.Strings(stood)
|
||||
return nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", "))
|
||||
for _, x := range d.StoodIn {
|
||||
// The bus's user list is the bus node's own to follow, written alone and never from a composition the node
|
||||
// made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied.
|
||||
if x != BusUsersStandIn {
|
||||
stood = append(stood, x)
|
||||
}
|
||||
}
|
||||
stood = append(stood, d.Foreseen...)
|
||||
for _, p := range d.ForeseenPorts {
|
||||
stood = append(stood, "port "+p)
|
||||
}
|
||||
sort.Strings(stood)
|
||||
stood = slices.Compact(stood) // an own secret is both stood in for and foreseen
|
||||
if applied && len(stood) > 0 {
|
||||
return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", "))
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var envelope struct {
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
@@ -86,19 +147,41 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
Provisioner string `json:"provisioner_account"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var kept []json.RawMessage
|
||||
keptPaths := map[string]bool{}
|
||||
foldedPaths := map[string]bool{}
|
||||
left := map[string]bool{}
|
||||
for _, raw := range envelope.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if isCarried(head.Module, head.ID) {
|
||||
continue // what the stub composed of it: the node's own carries what runs of it instead
|
||||
}
|
||||
if folds(head.Module) {
|
||||
// Into the node's own, in its place among the node's resources, its module taken off (mesh-controller's
|
||||
// compositionsOf): the node's own composition is then the controller's, byte for byte.
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
delete(fields, "module")
|
||||
if raw, err = json.Marshal(fields); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if head.Type == string(declaration.TypeFile) {
|
||||
foldedPaths[head.Path] = true
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
continue
|
||||
}
|
||||
if _, ok := fps[head.Module]; head.Module != "" && !ok {
|
||||
left[head.Module] = true
|
||||
@@ -109,16 +192,42 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
}
|
||||
var filled []string
|
||||
// What runs of each carried module, in the node's own composition, each after the resource it runs after.
|
||||
if len(carried) > 0 {
|
||||
var err error
|
||||
if kept, foldedPaths, err = carry(kept, foldedPaths, runsOwn, isCarried); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
for _, p := range runsOwn.Filled {
|
||||
if foldedPaths[p] && !slices.Contains(envelope.Filled, p) {
|
||||
envelope.Filled = append(envelope.Filled, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A filled file of a module's own composition goes with that module (SplitComposed); one of a folded module goes with
|
||||
// the node's own, after them, in the declaration's order; one of a module left out is dropped.
|
||||
var filled, ownFilled []string
|
||||
for _, p := range envelope.Filled {
|
||||
if keptPaths[p] {
|
||||
switch {
|
||||
case foldedPaths[p]:
|
||||
ownFilled = append(ownFilled, p)
|
||||
case keptPaths[p]:
|
||||
filled = append(filled, p)
|
||||
}
|
||||
}
|
||||
out, err = delivery.SplitComposed(node, kept, fps, filled,
|
||||
delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(ownFilled) > 0 {
|
||||
own, err := delivery.ComposedNodeKey(node)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
c := out[own]
|
||||
c.Filled = append(c.Filled, ownFilled...)
|
||||
out[own] = c
|
||||
}
|
||||
for key, c := range out {
|
||||
if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" {
|
||||
@@ -130,5 +239,85 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
skipped = append(skipped, m)
|
||||
}
|
||||
sort.Strings(skipped)
|
||||
return out, skipped, nil
|
||||
return out, skipped, stood, nil
|
||||
}
|
||||
|
||||
// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
|
||||
// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
|
||||
// alone (issue 1126).
|
||||
func NamesBuild(m catalogue.Manifest) bool {
|
||||
if m.Build != nil {
|
||||
return true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, ok := r["artifact"]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Stub is m with nothing to build and no resource but its directories: what it provides, requires, serves and grants
|
||||
// still shape the rest of the composition, the places its own secrets, data and grants name (${dir:…}) still resolve,
|
||||
// and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox).
|
||||
func Stub(m catalogue.Manifest) catalogue.Manifest {
|
||||
m.Build = nil
|
||||
var dirs []map[string]any
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "directory" {
|
||||
dirs = append(dirs, r)
|
||||
}
|
||||
}
|
||||
m.Resources = dirs
|
||||
return m
|
||||
}
|
||||
|
||||
// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as
|
||||
// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is
|
||||
// not among them; a file among them is a folded path.
|
||||
func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) {
|
||||
idOf := func(raw json.RawMessage) (string, string, string, string, error) {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
err := json.Unmarshal(raw, &head)
|
||||
return head.ID, head.Module, head.Type, head.Path, err
|
||||
}
|
||||
present := map[string]bool{}
|
||||
for _, raw := range kept {
|
||||
if id, module, _, _, err := idOf(raw); err == nil && module == "" {
|
||||
present[id] = true
|
||||
}
|
||||
}
|
||||
// By the id of the resource of the node's own it runs after, "" for first: the last one before it that is composed.
|
||||
after := map[string][]json.RawMessage{}
|
||||
anchor := ""
|
||||
for _, raw := range runs.Resources {
|
||||
id, module, typ, path, err := idOf(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("compose: a resource the node runs in its own composition: %w", err)
|
||||
}
|
||||
if !carried(module, id) {
|
||||
if module == "" && present[id] {
|
||||
anchor = id
|
||||
}
|
||||
continue
|
||||
}
|
||||
after[anchor] = append(after[anchor], raw)
|
||||
if typ == string(declaration.TypeFile) {
|
||||
foldedPaths[path] = true
|
||||
}
|
||||
}
|
||||
out := make([]json.RawMessage, 0, len(kept)+len(runs.Resources))
|
||||
out = append(out, after[""]...)
|
||||
for _, raw := range kept {
|
||||
out = append(out, raw)
|
||||
if id, module, _, _, err := idOf(raw); err == nil && module == "" {
|
||||
out = append(out, after[id]...)
|
||||
}
|
||||
}
|
||||
return out, foldedPaths, nil
|
||||
}
|
||||
|
||||
@@ -58,15 +58,41 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
t.Errorf("the compositions hold %v, the declaration %v", got, want)
|
||||
}
|
||||
|
||||
one, skipped, err := Compositions(in, "laptop", map[string]string{"files": fpFor('b')}, Foreseeing)
|
||||
if err != nil || strings.Join(skipped, ",") != "mesh-wireguard" {
|
||||
one, skipped, err := Compositions(in, "laptop", map[string]string{"mesh-wireguard": fpFor('a')}, Foreseeing)
|
||||
if err != nil || strings.Join(skipped, ",") != "files" {
|
||||
t.Fatalf("one module: %v, skipped %v", err, skipped)
|
||||
}
|
||||
for _, c := range one {
|
||||
if c.Module == "mesh-wireguard" {
|
||||
if c.Module == "files" {
|
||||
t.Errorf("a module given no fingerprint was composed")
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1126: a module the node runs inside its own composition (Node.InOwn), and the private network's always,
|
||||
// given no fingerprint of the new form, is folded into the node's own as the controller's composed writer folds it:
|
||||
// every resource in its place, its module taken off, its id still naming it, nothing skipped.
|
||||
folding := in
|
||||
folding.Nodes = append([]Node(nil), in.Nodes...)
|
||||
for i := range folding.Nodes {
|
||||
if folding.Nodes[i].Name == "laptop" {
|
||||
folding.Nodes[i].InOwn = map[string]bool{"files": true}
|
||||
}
|
||||
}
|
||||
all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing)
|
||||
nodeKey, _ := delivery.ComposedNodeKey("laptop")
|
||||
if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) {
|
||||
t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want))
|
||||
}
|
||||
for i, raw := range all[nodeKey].Resources {
|
||||
var r map[string]any
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
if _, says := r["module"]; says || r["id"] != d.Resources[i]["id"] {
|
||||
t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"])
|
||||
}
|
||||
}
|
||||
if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil {
|
||||
t.Errorf("the node's own with the folded modules: %v", err)
|
||||
}
|
||||
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
|
||||
t.Errorf("a node the mesh does not have was composed")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Answer is a node's answer to its group's placement candidates (novox/hq ADR 0559 §4): s.Node composed in shadow with
|
||||
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
|
||||
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
|
||||
// nothing; head and revision are the candidate's, said back in the answer.
|
||||
//
|
||||
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
|
||||
// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
|
||||
// composition stands in for is judged: a value of a module the group adds (on this node or another: its credential, own
|
||||
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
|
||||
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
|
||||
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, head string, revision uint64) delivery.CandidateAnswer {
|
||||
a := delivery.CandidateAnswer{Head: head, Revision: revision}
|
||||
refuse := func(err error) delivery.CandidateAnswer {
|
||||
a.Refused = err.Error()
|
||||
a.Verdict = delivery.CandidateRefused
|
||||
return a
|
||||
}
|
||||
trunk, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return refuse(fmt.Errorf("frombus: placement: %w", err))
|
||||
}
|
||||
if s.Running == nil {
|
||||
s.Running = running
|
||||
}
|
||||
// Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and
|
||||
// below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
|
||||
failed := map[string]string{}
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() })
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
// What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the
|
||||
// whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is
|
||||
// assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet
|
||||
// issued under Added (review of L2b, round 1, finding 1).
|
||||
runs := map[string]bool{}
|
||||
for _, c := range running {
|
||||
if c.Module != "" {
|
||||
runs[c.Module] = true
|
||||
}
|
||||
}
|
||||
var consumed []compose.CredentialKey
|
||||
for _, k := range asConsumer {
|
||||
if runs[k.ConsumerModule] {
|
||||
consumed = append(consumed, k)
|
||||
}
|
||||
}
|
||||
asConsumer = consumed
|
||||
if len(asConsumer)+len(asProvider) > 0 {
|
||||
if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
|
||||
return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
|
||||
}
|
||||
}
|
||||
composed, skipped, stood, err := compose.CompositionsShadow(in, s.Node, fps)
|
||||
for err != nil {
|
||||
module := culprit(in, s.Node, fps, failed, err)
|
||||
if module == "" {
|
||||
// Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's.
|
||||
if len(failed) == 0 {
|
||||
return refuse(err)
|
||||
}
|
||||
failed[delivery.HaveNodeWord] = err.Error()
|
||||
composed, skipped, stood = nil, nil, nil
|
||||
break
|
||||
}
|
||||
failed[module] = err.Error()
|
||||
in = stubbed(in, s.Node, module)
|
||||
composed, skipped, stood, err = compose.CompositionsShadow(in, s.Node, fps)
|
||||
}
|
||||
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
|
||||
// names it (the node agent's own composer hands it on that want alone), so a candidate does not add it.
|
||||
runsOwn := false
|
||||
for _, c := range running {
|
||||
runsOwn = runsOwn || c.Module == ""
|
||||
}
|
||||
if !runsOwn {
|
||||
for key, c := range composed {
|
||||
if c.Module == "" {
|
||||
delete(composed, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, whole := failed[delivery.HaveNodeWord]; whole {
|
||||
// Nothing composed to compare: every refusal is said, and nothing else.
|
||||
for _, m := range sortedKeys(failed) {
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
|
||||
}
|
||||
a.Verdict = delivery.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
// A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from
|
||||
// the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531).
|
||||
if len(s.InOwn) > 0 {
|
||||
var judged []delivery.Composed
|
||||
for _, c := range running {
|
||||
if c.Module == "" || !slices.Contains(s.InOwn, c.Module) {
|
||||
judged = append(judged, c)
|
||||
}
|
||||
}
|
||||
running = judged
|
||||
}
|
||||
cmp := delivery.CompareComposedWith(composed, running, s.SameValue)
|
||||
// The answer rides in the node's have key: its shown bodies are bounded, the rest named only.
|
||||
budget := MaxShown
|
||||
for i := range cmp.Differs {
|
||||
kept := cmp.Differs[i].Bodies[:0]
|
||||
for _, b := range cmp.Differs[i].Bodies {
|
||||
if n := len(b.Composed) + len(b.Running); n <= budget {
|
||||
budget -= n
|
||||
kept = append(kept, b)
|
||||
} else {
|
||||
kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why})
|
||||
a.Note = "some differing resources are named without their bodies: the answer holds at most " +
|
||||
fmt.Sprint(MaxShown) + " bytes of them"
|
||||
}
|
||||
}
|
||||
cmp.Differs[i].Bodies = kept
|
||||
}
|
||||
// A refused module was composed as a stub: what it composed is no difference; its refusal is said.
|
||||
notFailed := func(m string) bool { _, ok := failed[m]; return !ok }
|
||||
a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) })
|
||||
a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) })
|
||||
a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) })
|
||||
cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
|
||||
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
|
||||
|
||||
// What the group adds, by node and module: placed by a candidate and not by the trunk on that node.
|
||||
added := map[string]bool{}
|
||||
for node, p := range s.Placements {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
was := map[string]bool{}
|
||||
for _, m := range trunk[node].Modules {
|
||||
was[m.Module] = true
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
if !was[m.Module] {
|
||||
added[node+"/"+m.Module] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
unissued := map[string][]string{}
|
||||
refusals := map[string][]string{}
|
||||
for _, x := range stood {
|
||||
node, module := standsInFor(x, s.Node)
|
||||
switch {
|
||||
case node == s.Node && runs[module]:
|
||||
// A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is
|
||||
// empty, and every module counts as the group's).
|
||||
refusals[module] = append(refusals[module], x)
|
||||
case module != "" && added[node+"/"+module]:
|
||||
if node == s.Node {
|
||||
unissued[module] = append(unissued[module], x)
|
||||
} // another node's addition: the module here that carries it differs, which the assigner judges
|
||||
case module == "" || node != s.Node:
|
||||
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
|
||||
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
|
||||
default:
|
||||
refusals[module] = append(refusals[module], x)
|
||||
}
|
||||
}
|
||||
addedHere := map[string]delivery.ModuleAdded{}
|
||||
for _, m := range cmp.Added {
|
||||
addedHere[m.Module] = m
|
||||
}
|
||||
for _, m := range skipped { // placed, and no build given to compose it at
|
||||
addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
|
||||
}
|
||||
for m, xs := range unissued {
|
||||
got := addedHere[m]
|
||||
got.Module = m
|
||||
got.Unissued = append(got.Unissued, xs...)
|
||||
addedHere[m] = got
|
||||
}
|
||||
for _, m := range sortedKeys(addedHere) {
|
||||
a.Added = append(a.Added, addedHere[m])
|
||||
}
|
||||
for _, m := range sortedKeys(refusals) {
|
||||
if _, isAdded := addedHere[m]; isAdded {
|
||||
continue // a module the candidate adds here: what it lacks is said under Added
|
||||
}
|
||||
if _, isFailed := failed[m]; isFailed {
|
||||
continue // said below, as why it could not be composed
|
||||
}
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
|
||||
}
|
||||
for _, m := range sortedKeys(failed) {
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
|
||||
}
|
||||
sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module })
|
||||
a.Verdict = delivery.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
|
||||
// maxCulprits bounds the modules one refusal is tried on, each a whole composition.
|
||||
const maxCulprits = 8
|
||||
|
||||
// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies).
|
||||
const MaxShown = 256 << 10
|
||||
|
||||
var (
|
||||
grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
|
||||
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
|
||||
)
|
||||
|
||||
// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
|
||||
// `port module:port`, a grant `the grant of <provision> for <module> on <node>`, a need `<module> on <node> needs ...`;
|
||||
// the module empty when the words name none (the certificate, the authority).
|
||||
func standsInFor(x, node string) (string, string) {
|
||||
if m := grantStandIn.FindStringSubmatch(x); m != nil {
|
||||
return m[2], m[1]
|
||||
}
|
||||
if m := needsStandIn.FindStringSubmatch(x); m != nil {
|
||||
return m[2], m[1]
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(x, "port "); ok {
|
||||
if module, _, ok := strings.Cut(rest, ":"); ok {
|
||||
return node, module
|
||||
}
|
||||
}
|
||||
if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") {
|
||||
return node, module
|
||||
}
|
||||
return node, ""
|
||||
}
|
||||
|
||||
// culprit is the module placed on node whose composition the error is of: the first, in placement order, whose stub
|
||||
// (compose.Stub) makes the composition succeed or fail otherwise; empty when none does (the error is not one module's).
|
||||
// Modules already refused are passed over.
|
||||
func culprit(in compose.Input, node string, fps map[string]string, failed map[string]string, err error) string {
|
||||
var assigned []string
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
assigned = n.Assigned
|
||||
}
|
||||
}
|
||||
// Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a
|
||||
// refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's
|
||||
// answer took 7m46s).
|
||||
var order []string
|
||||
for _, m := range assigned {
|
||||
if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
|
||||
order = append(order, m)
|
||||
}
|
||||
}
|
||||
for _, m := range order {
|
||||
if _, done := failed[m]; done {
|
||||
continue
|
||||
}
|
||||
if _, _, _, again := compose.CompositionsShadow(stubbed(in, node, m), node, fps); again == nil || again.Error() != err.Error() {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stubbed is in with module composed as a stub on node alone (compose.Stub, as node's Kept).
|
||||
func stubbed(in compose.Input, node, module string) compose.Input {
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
for i := range out.Nodes {
|
||||
if out.Nodes[i].Name != node {
|
||||
continue
|
||||
}
|
||||
man, ok := out.Nodes[i].Kept[module]
|
||||
if !ok {
|
||||
if man, ok = in.Catalogue[module]; !ok {
|
||||
return in
|
||||
}
|
||||
}
|
||||
kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1)
|
||||
for k, v := range out.Nodes[i].Kept {
|
||||
kept[k] = v
|
||||
}
|
||||
kept[module] = compose.Stub(man)
|
||||
out.Nodes[i].Kept = kept
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only;
|
||||
// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos,
|
||||
// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs.
|
||||
func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
image := "alpine@sha256:" + strings.Repeat("a", 64)
|
||||
manifest := func(module string) []byte {
|
||||
// Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused
|
||||
// "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory).
|
||||
return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` +
|
||||
`"own-secrets":{"token":"${dir:state}/token"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` +
|
||||
`{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` +
|
||||
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
|
||||
}
|
||||
lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
|
||||
fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
|
||||
mk := func(placed ...string) *bus {
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
for _, m := range placed {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
b := &bus{placements: map[string]delivery.Placement{"n1": p},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
|
||||
modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
|
||||
builds: map[string]*delivery.BuilderBuild{}}
|
||||
for _, m := range []string{"web", "api", "spiegel"} {
|
||||
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
|
||||
}
|
||||
for _, m := range []string{"web", "api", "spiegel"} {
|
||||
raw := manifest(m)
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
// web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of
|
||||
// the new form at all.
|
||||
for i, m := range []string{"web", "api"} {
|
||||
mod := b.modules[m]
|
||||
mod.FP2 = fp(string(rune('1' + i)))
|
||||
b.modules[m] = mod
|
||||
b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
|
||||
}
|
||||
return b
|
||||
}
|
||||
given := func(b *bus) map[string]*delivery.Placement {
|
||||
p := b.placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
}
|
||||
|
||||
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
|
||||
b := mk("lemurs", "web", "api", "spiegel")
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "h1", 3)
|
||||
var refused []string
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
}
|
||||
if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
|
||||
t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a)
|
||||
}
|
||||
for _, r := range a.Refusals {
|
||||
want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site",
|
||||
"spiegel": "spiegel has not been built for this mesh"}[r.Module]
|
||||
if !strings.Contains(r.Why, want) {
|
||||
t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
|
||||
}
|
||||
}
|
||||
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
|
||||
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
|
||||
}
|
||||
if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK {
|
||||
t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a)
|
||||
}
|
||||
|
||||
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
|
||||
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
|
||||
b = mk("lemurs", overlay.Name, "spiegel")
|
||||
p := b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
fps := map[string]string{"lemurs": fp("e")}
|
||||
stub := Scope{Node: "n1"}
|
||||
in, err := Input(ctx, b, Mesh{}, stub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
|
||||
base, _, err := compose.Compositions(in, "n1", fps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's
|
||||
// answer took 7m46s, one compose per module), though here a stub would compose.
|
||||
if m := culprit(in, "n1", fps, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" {
|
||||
t.Errorf("a refusal naming no placed module was laid on %s", m)
|
||||
}
|
||||
ownKey, _ := delivery.ComposedNodeKey("n1")
|
||||
own := base[ownKey]
|
||||
carriedRes := []json.RawMessage{
|
||||
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
|
||||
// They run after the node's first own resource.
|
||||
own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...)
|
||||
var running []delivery.Composed
|
||||
for k, c := range base {
|
||||
if k == ownKey {
|
||||
c = own
|
||||
}
|
||||
running = append(running, c)
|
||||
}
|
||||
scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}
|
||||
got, _, err := Compose(ctx, b, Mesh{}, scope, fps)
|
||||
if err != nil {
|
||||
t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err)
|
||||
}
|
||||
gotOwn, _ := json.Marshal(got[ownKey].Resources)
|
||||
wantOwn, _ := json.Marshal(own.Resources)
|
||||
if string(gotOwn) != string(wantOwn) {
|
||||
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
|
||||
}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "h1", 3)
|
||||
if a.Verdict != delivery.CandidateSame {
|
||||
t.Errorf("the fold's answer: %+v, want the same", a)
|
||||
}
|
||||
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
|
||||
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
|
||||
keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
|
||||
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
|
||||
}
|
||||
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
|
||||
// the same, not 'in what runs, not composed' (seed #531, novox).
|
||||
membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`)
|
||||
withMembership := make([]delivery.Composed, 0, len(running))
|
||||
for _, c := range running {
|
||||
if c.Module == "" {
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership)
|
||||
}
|
||||
withMembership = append(withMembership, c)
|
||||
}
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "h1", 3); a.Verdict != delivery.CandidateSame {
|
||||
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
|
||||
}
|
||||
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
|
||||
if _, _, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err == nil ||
|
||||
!strings.Contains(err.Error(), "spiegel has not been built for this mesh") {
|
||||
t.Errorf("composed spiegel without what runs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module another node cannot run in this composition offers nothing in it: the refusal names it and why
|
||||
// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a
|
||||
// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module
|
||||
// (ace took 7m46s).
|
||||
func otherNodeNotResolvedIsNamed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`)
|
||||
cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`)
|
||||
// n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here.
|
||||
b := &bus{placements: map[string]delivery.Placement{
|
||||
"n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
|
||||
"n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
|
||||
modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
|
||||
given := map[string]*delivery.Placement{}
|
||||
for n, p := range b.placements {
|
||||
p := p
|
||||
given[n] = &p
|
||||
}
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "h", 1)
|
||||
if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
|
||||
t.Errorf("a refusal for want of n2's provider = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered
|
||||
// "reaches no artifact store", and nothing said why).
|
||||
func noStoreReachedSaysWhy(t *testing.T) {
|
||||
in := compose.Input{Catalogue: map[string]catalogue.Manifest{
|
||||
"distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
|
||||
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
|
||||
if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" {
|
||||
t.Errorf("said %q", got)
|
||||
}
|
||||
in.Nodes = in.Nodes[:1]
|
||||
if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" {
|
||||
t.Errorf("said %q", got)
|
||||
}
|
||||
}
|
||||
+408
-25
@@ -14,10 +14,13 @@
|
||||
// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the
|
||||
// node's own have key, `<node>.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided,
|
||||
// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both;
|
||||
// - every module's manifest: the indexer's `modules.<module>` and its manifests by digest, or the digest the
|
||||
// composing node's want names where it runs another build (Scope.Kept);
|
||||
// - every module's manifest: the indexer's `modules.<module>` and its manifests by digest; for each module placed on
|
||||
// the composing node, its built manifest instead: the builder's done key at the trunk's FP2, or at the fingerprint
|
||||
// the node's want names where it runs another build (Scope.KeptAt), resolved by the controller's own take-in rule
|
||||
// (takein.Manifest), a moved module's templates archive read in the artifact store (issue 1126);
|
||||
// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the
|
||||
// caller composes every node (ADR 0547 §2, the prepare step's L4).
|
||||
// caller composes every node (ADR 0547 §2, the prepare step's L4); of another node, only the keys its modules'
|
||||
// served values fill (catalogue.ServedSettings), which every consumer is told (issue 1126).
|
||||
//
|
||||
// **Own secrets and pair credentials** come from the vault sealed to the composing node's key (OwnSecrets, Credentials),
|
||||
// for that node alone: as a consumer and as a provider, exactly the keys compose.CredentialsNeeded names. **Not yet
|
||||
@@ -32,13 +35,16 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/takein"
|
||||
)
|
||||
|
||||
// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a
|
||||
@@ -63,6 +69,28 @@ type Reader interface {
|
||||
// and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key
|
||||
// the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it.
|
||||
Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error)
|
||||
// Build is a module's build at a fingerprint, the builder's key (delivery.BuilderBuildsBucket, BuilderBuildKey); nil
|
||||
// when the builder holds none. The composing node composes each of its modules from its build (issue 1126).
|
||||
Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error)
|
||||
// Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it
|
||||
// (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the
|
||||
// controller's take-in reads it (takein.Built).
|
||||
Archive(ctx context.Context, url, digest string) ([]byte, error)
|
||||
// Served is, of one module's config values on another node than the composing one (mesh-config's state `resolved`,
|
||||
// keys `<node>.<module>.<key>`), the keys named, decoded: the keys its served values and zone name fill
|
||||
// (catalogue.ServedSettings), which every consumer is told and so every node may read (issue 1126). Nil when none
|
||||
// is set.
|
||||
Served(ctx context.Context, node, module string, keys []string) (map[string]any, error)
|
||||
}
|
||||
|
||||
// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket,
|
||||
// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the
|
||||
// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before.
|
||||
type SignersReader interface {
|
||||
// VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the
|
||||
// vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the
|
||||
// state yet composes as before rather than not at all.
|
||||
VaultAuthority(ctx context.Context) string
|
||||
}
|
||||
|
||||
// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the
|
||||
@@ -78,20 +106,101 @@ type Mesh struct {
|
||||
}
|
||||
|
||||
// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the
|
||||
// composition check sets it to compose every node. Kept is, by module, the manifest digest the composing node's wants
|
||||
// name where it differs from the indexer's trunk: that node composes the module as it runs it.
|
||||
// composition check sets it to compose every node (of another node's modules, only the values their served values fill
|
||||
// are read, Reader.Served). KeptAt is, by module, the fingerprint the composing node's want names where it differs from
|
||||
// the indexer's trunk: that node composes the module from its build at that fingerprint, as it runs it.
|
||||
//
|
||||
// **Composed from its build** (issue 1126): each module placed on the composing node, and each in KeptAt, composes from
|
||||
// its built manifest (takein.Manifest over the builder's done key at the trunk's FP2 or the kept fingerprint), as the
|
||||
// controller composes from the manifest its take-in resolved: so a moved module is handed to the provisioner, a resource
|
||||
// naming an artifact names what was built, and a templated file has its text. A placed module the builder holds no done
|
||||
// build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer
|
||||
// read it: the check of every node judges placement, and reads no archive.
|
||||
//
|
||||
// Placements, when set, stands in for the indexer's placement of the nodes it names: a node's candidate placement
|
||||
// (delivery.PlacementCandidate), so a node composes in shadow with a catalogue pull request's placement and compares
|
||||
// the result with what it runs (issue 1126); a nil value removes the node's placement. The rest is read as always.
|
||||
//
|
||||
// InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again
|
||||
// (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126).
|
||||
//
|
||||
// Running is what the composing node runs; of it, its own composition (`<node>.node`) is given to the composition
|
||||
// (compose.Node.RunsOwn), so a module folded there that names a build of the old form is left to the node, carried
|
||||
// as it runs (issue 1126). Nil composes every folded module from its manifest.
|
||||
type Scope struct {
|
||||
Node string
|
||||
Every bool
|
||||
Kept map[string]string
|
||||
Node string
|
||||
Every bool
|
||||
KeptAt map[string]string
|
||||
Placements map[string]*delivery.Placement
|
||||
InOwn []string
|
||||
Running []delivery.Composed
|
||||
// SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own
|
||||
// key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them
|
||||
// as differing.
|
||||
SameValue func(a, b string) (same, known bool)
|
||||
}
|
||||
|
||||
// InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs
|
||||
// (`<node>.node` in running) carries, by the module their ids name (declaration.ModuleGuess), sorted: the modules the
|
||||
// controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module
|
||||
// run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own
|
||||
// composition keeps them as it runs them until a want names a build of the new form for one.
|
||||
func InOwn(running []delivery.Composed, p delivery.Placement) []string {
|
||||
placed := map[string]bool{}
|
||||
for _, m := range p.Modules {
|
||||
placed[m.Module] = true
|
||||
}
|
||||
in := map[string]bool{}
|
||||
for _, c := range running {
|
||||
if c.Module != "" {
|
||||
continue
|
||||
}
|
||||
for _, raw := range c.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
if json.Unmarshal(raw, &head) != nil || head.Module != "" {
|
||||
continue
|
||||
}
|
||||
if m, ok := declaration.ModuleGuess(head.ID); ok && placed[m] {
|
||||
in[m] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortedKeys(in)
|
||||
}
|
||||
|
||||
// Input is the composition input for s, read through r.
|
||||
func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) {
|
||||
return input(ctx, r, mesh, s, nil)
|
||||
}
|
||||
|
||||
// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in is
|
||||
// handed to refuse and composed as a stub (compose.Stub, kept for that node alone) rather than ending the read, so a
|
||||
// node's answer names every module it cannot compose (issue 1126).
|
||||
func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error)) (compose.Input, error) {
|
||||
placements, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
|
||||
}
|
||||
if len(s.Placements) > 0 {
|
||||
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
|
||||
for node, p := range placements {
|
||||
given[node] = p
|
||||
}
|
||||
for node, p := range s.Placements {
|
||||
if p == nil {
|
||||
delete(given, node)
|
||||
continue
|
||||
}
|
||||
if p.Node != node {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
}
|
||||
given[node] = *p
|
||||
}
|
||||
placements = given
|
||||
}
|
||||
records, err := r.Records(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
|
||||
@@ -120,15 +229,25 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the vault's authority beside it, once the vault publishes one (ADR 0550 §2): a certificate either signed checks
|
||||
// against the bundle. The controller's stays while a certificate it issued is in a node's record.
|
||||
if sr, ok := r.(SignersReader); ok {
|
||||
if vault := sr.VaultAuthority(ctx); vault != "" {
|
||||
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
|
||||
}
|
||||
}
|
||||
parse := func(raw []byte) (catalogue.Manifest, error) {
|
||||
if mesh.Predefined != nil {
|
||||
return catalogue.ParseManifestWith(raw, mesh.Predefined)
|
||||
}
|
||||
return catalogue.ParseManifestForInputs(raw)
|
||||
}
|
||||
read := func(module, digest string) (catalogue.Manifest, error) {
|
||||
raw, err := r.Manifest(ctx, digest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
|
||||
}
|
||||
if mesh.Predefined != nil {
|
||||
return catalogue.ParseManifestWith(raw, mesh.Predefined)
|
||||
}
|
||||
return catalogue.ParseManifestForInputs(raw)
|
||||
return parse(raw)
|
||||
}
|
||||
for _, name := range sortedKeys(modules) {
|
||||
m := modules[name]
|
||||
@@ -172,6 +291,13 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
|
||||
if values, err = r.Values(ctx, node, m.Module); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
|
||||
}
|
||||
} else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 {
|
||||
// What a provider elsewhere serves from its settings is told to every consumer: the composing node reads
|
||||
// those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks).
|
||||
if values, err = r.Served(ctx, node, m.Module, keys); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
|
||||
strings.Join(keys, ", "), err)
|
||||
}
|
||||
}
|
||||
if layers := m.Layers(node, values); layers != nil {
|
||||
if n.Settings == nil {
|
||||
@@ -181,15 +307,27 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
|
||||
}
|
||||
}
|
||||
if node == s.Node {
|
||||
for _, module := range sortedKeys(s.Kept) {
|
||||
man, err := read(module, s.Kept[module])
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
for _, module := range s.InOwn {
|
||||
if n.InOwn == nil {
|
||||
n.InOwn = map[string]bool{}
|
||||
}
|
||||
if n.Kept == nil {
|
||||
n.Kept = map[string]catalogue.Manifest{}
|
||||
n.InOwn[module] = true
|
||||
}
|
||||
for i := range s.Running {
|
||||
if s.Running[i].Module == "" {
|
||||
own := s.Running[i]
|
||||
n.RunsOwn = &own
|
||||
// Its bus membership as it runs it: the controller composes it from its store, which the node does
|
||||
// not read; until the grants part hands memberships sealed to the node, the node's own composition
|
||||
// carries the one it runs (issue 1126, seed #531 on novox: "bus-membership in what runs, not
|
||||
// composed", which applied would have taken the node's bus credential away).
|
||||
if sealed := membershipOf(own); sealed != "" {
|
||||
if in.BusMemberships == nil {
|
||||
in.BusMemberships = map[string]string{}
|
||||
}
|
||||
in.BusMemberships[s.Node] = sealed
|
||||
}
|
||||
}
|
||||
n.Kept[module] = man
|
||||
}
|
||||
}
|
||||
in.Nodes = append(in.Nodes, n)
|
||||
@@ -220,6 +358,9 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
|
||||
if _, ok := records[s.Node]; !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
|
||||
}
|
||||
if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse); err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
|
||||
// Asked only when a module placed on it declares one: a node none of whose modules has a secret composes
|
||||
// whether or not the vault answers.
|
||||
@@ -261,6 +402,137 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
|
||||
return compose.CompositionsApplied(in, s.Node, fps)
|
||||
}
|
||||
|
||||
// fromBuilds puts in the built manifest of each module placed on the composing node, at the trunk's FP2, and of each
|
||||
// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
|
||||
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
|
||||
// (issue 1126).
|
||||
func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
||||
read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
|
||||
refuse func(module string, err error)) error {
|
||||
// stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
|
||||
// and requires still shapes the rest.
|
||||
stub := func(module string, err error) {
|
||||
refuse(module, err)
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name != s.Node {
|
||||
continue
|
||||
}
|
||||
man, ok := in.Nodes[i].Kept[module]
|
||||
if !ok {
|
||||
man, ok = in.Catalogue[module]
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if in.Nodes[i].Kept == nil {
|
||||
in.Nodes[i].Kept = map[string]catalogue.Manifest{}
|
||||
}
|
||||
in.Nodes[i].Kept[module] = compose.Stub(man)
|
||||
}
|
||||
}
|
||||
address, addressErr := "", error(nil)
|
||||
addressed := false
|
||||
archive := func(ctx context.Context, reference, digest string) ([]byte, error) {
|
||||
if !addressed {
|
||||
address, addressErr = compose.ArtifactStoreAddress(*in, s.Node)
|
||||
addressed = true
|
||||
}
|
||||
switch {
|
||||
case addressErr != nil:
|
||||
return nil, addressErr
|
||||
case address == "":
|
||||
return nil, fmt.Errorf("%s reaches no artifact store (%s)", s.Node, storeSought(*in))
|
||||
}
|
||||
return r.Archive(ctx, catalogue.Routed(reference, address), digest)
|
||||
}
|
||||
built := func(module, fp string, b *delivery.BuilderBuild) (catalogue.Manifest, error) {
|
||||
if b.Manifest == "" {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp)
|
||||
}
|
||||
src, err := read(module, b.Manifest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
}
|
||||
resolved, err := takein.Manifest(ctx, src, *b, archive)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: %s at %s: %w", module, fp, err)
|
||||
}
|
||||
// Read back as the mesh holds a built manifest (the controller keeps the resolved manifest's bytes and reads them
|
||||
// as any manifest): a template's text is then in the manifest's own words, its ${config:…} in the settings form.
|
||||
raw, err := json.Marshal(resolved)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
}
|
||||
if resolved, err = parse(raw); err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
for _, pm := range p.Modules {
|
||||
m, ok := modules[pm.Module]
|
||||
if !ok || m.FP2 == "" || s.KeptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) {
|
||||
// A module it runs inside its own composition is composed there as before: no build of the new form is what
|
||||
// it runs (Scope.InOwn).
|
||||
continue
|
||||
}
|
||||
if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) {
|
||||
continue // nothing is built of it: its manifest is what it composes from
|
||||
}
|
||||
b, err := r.Build(ctx, pm.Module, m.FP2)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err)
|
||||
if refuse == nil {
|
||||
return err
|
||||
}
|
||||
stub(pm.Module, err)
|
||||
continue
|
||||
}
|
||||
if b == nil || b.State != delivery.BuildDone {
|
||||
continue // no done build at the trunk's: composed from the indexer's manifest, as before
|
||||
}
|
||||
resolved, err := built(pm.Module, m.FP2, b)
|
||||
if err != nil {
|
||||
if refuse == nil {
|
||||
return err
|
||||
}
|
||||
stub(pm.Module, err)
|
||||
continue
|
||||
}
|
||||
in.Catalogue[pm.Module] = resolved
|
||||
}
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name != s.Node {
|
||||
continue
|
||||
}
|
||||
for _, module := range sortedKeys(s.KeptAt) {
|
||||
fp := s.KeptAt[module]
|
||||
b, err := r.Build(ctx, module, fp)
|
||||
switch {
|
||||
case err != nil:
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err)
|
||||
case b == nil || b.State != delivery.BuildDone:
|
||||
err = fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build", module, fp)
|
||||
}
|
||||
var resolved catalogue.Manifest
|
||||
if err == nil {
|
||||
resolved, err = built(module, fp, b)
|
||||
}
|
||||
if err != nil {
|
||||
if refuse == nil {
|
||||
return err
|
||||
}
|
||||
stub(module, err)
|
||||
continue
|
||||
}
|
||||
if in.Nodes[i].Kept == nil {
|
||||
in.Nodes[i].Kept = map[string]catalogue.Manifest{}
|
||||
}
|
||||
in.Nodes[i].Kept[module] = resolved
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// declaresOwnSecrets says a module assigned to node declares an own secret, in the manifest the node composes it from.
|
||||
func declaresOwnSecrets(in compose.Input, node string) bool {
|
||||
for _, n := range in.Nodes {
|
||||
@@ -283,16 +555,14 @@ func declaresOwnSecrets(in compose.Input, node string) bool {
|
||||
// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person
|
||||
// decided of it, and where its modules run).
|
||||
func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
|
||||
d := p.Decided
|
||||
n := compose.Node{
|
||||
Name: rec.Node,
|
||||
Place: compose.Place{Key: rec.TunnelKey, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address},
|
||||
Assigned: p.Assigned(), PublicDomain: d.PublicDomain,
|
||||
Account: d.Account, AccountHome: d.AccountHome, AgentAccount: d.AgentAccount, AgentAccountHome: d.AgentHome,
|
||||
Adopted: d.Adopted, Taken: d.Taken, Pins: p.ChosenPins(), Bound: p.Bound(), Ports: p.HostPorts(), SealingKey: rec.SealingKey,
|
||||
Name: rec.Node,
|
||||
Place: compose.Place{Key: rec.TunnelKey},
|
||||
SealingKey: rec.SealingKey,
|
||||
OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
|
||||
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0,
|
||||
}
|
||||
placeNode(&n, p)
|
||||
if len(rec.Capabilities) > 0 {
|
||||
n.Capabilities = make(map[string]bool, len(rec.Capabilities))
|
||||
for _, c := range rec.Capabilities {
|
||||
@@ -302,6 +572,80 @@ func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
|
||||
return n
|
||||
}
|
||||
|
||||
// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes
|
||||
// (its keys, links, networks, certificate) and its settings layers are left as they are.
|
||||
func placeNode(n *compose.Node, p delivery.Placement) {
|
||||
d := p.Decided
|
||||
n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address}
|
||||
n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain
|
||||
n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome
|
||||
n.Adopted, n.Taken = d.Adopted, d.Taken
|
||||
n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts()
|
||||
}
|
||||
|
||||
// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's
|
||||
// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its
|
||||
// config values those in already carries for it (the layer of the node's own name), and what the machine observes is
|
||||
// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has
|
||||
// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given
|
||||
// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's
|
||||
// Placements).
|
||||
func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) {
|
||||
if len(placements) == 0 {
|
||||
return in, nil
|
||||
}
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
index := map[string]int{}
|
||||
for i, n := range out.Nodes {
|
||||
index[n.Name] = i
|
||||
}
|
||||
for _, node := range sortedKeys(placements) {
|
||||
p := placements[node]
|
||||
i, ok := index[node]
|
||||
if !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node)
|
||||
}
|
||||
if p == nil {
|
||||
p = &delivery.Placement{Node: node}
|
||||
}
|
||||
if p.Node != node {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
}
|
||||
n := out.Nodes[i]
|
||||
placeNode(&n, *p)
|
||||
settings := map[string][]catalogue.Layer{}
|
||||
for _, m := range p.Modules {
|
||||
var values map[string]any
|
||||
for _, l := range n.Settings[m.Module] {
|
||||
if l.From == node {
|
||||
values = l.Values
|
||||
}
|
||||
}
|
||||
if layers := m.Layers(node, values); layers != nil {
|
||||
settings[m.Module] = layers
|
||||
}
|
||||
}
|
||||
n.Settings = nil
|
||||
if len(settings) > 0 {
|
||||
n.Settings = settings
|
||||
}
|
||||
out.Nodes[i] = n
|
||||
}
|
||||
out.Holdings = nil
|
||||
for _, h := range in.Holdings {
|
||||
if _, given := placements[h.Node]; !given {
|
||||
out.Holdings = append(out.Holdings, h)
|
||||
}
|
||||
}
|
||||
for _, node := range sortedKeys(placements) {
|
||||
if p := placements[node]; p != nil {
|
||||
out.Holdings = append(out.Holdings, p.Held()...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func tunnelOf(t delivery.RecordTunnel) compose.Tunnel {
|
||||
out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
|
||||
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
@@ -356,3 +700,42 @@ func sortedKeys[V any](m map[string]V) []string {
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and
|
||||
// where each module serving the artifact store is placed, so the refusal names why (issue 1126).
|
||||
func storeSought(in compose.Input) string {
|
||||
var nodes, where []string
|
||||
for _, n := range in.Nodes {
|
||||
nodes = append(nodes, n.Name)
|
||||
for _, a := range n.Assigned {
|
||||
if m, ok := in.Catalogue[a]; ok {
|
||||
if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves {
|
||||
where = append(where, a+" on "+n.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
sort.Strings(where)
|
||||
said := "the composition holds " + strings.Join(nodes, ", ")
|
||||
if len(where) == 0 {
|
||||
return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision
|
||||
}
|
||||
return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") +
|
||||
", none of it on the private network with a port"
|
||||
}
|
||||
|
||||
// membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own
|
||||
// composition, or "".
|
||||
func membershipOf(own delivery.Composed) string {
|
||||
for _, raw := range own.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Sealed string `json:"sealed"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) == nil && r.ID == catalogue.BusMembershipID() {
|
||||
return r.Sealed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -20,19 +25,57 @@ import (
|
||||
|
||||
// bus is a Reader over values held in memory, counting whose config values were read.
|
||||
type bus struct {
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
manifests map[string][]byte
|
||||
values map[string]map[string]any // by node.module
|
||||
valuesRead []string
|
||||
own []compose.OwnSecretRow
|
||||
ownErr error
|
||||
vaultAuthority string
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
manifests map[string][]byte
|
||||
values map[string]map[string]any // by node.module
|
||||
valuesRead []string
|
||||
own []compose.OwnSecretRow
|
||||
ownErr error
|
||||
// asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming
|
||||
// module its placement does not put on the node.
|
||||
asked []compose.CredentialKey
|
||||
// builds are the builder's keys, by "<module> <fp>"; archives the artifact store's blobs, by digest, and fetched
|
||||
// every URL asked; served another node's values by "<node>.<module>", and servedRead every ask with its keys.
|
||||
builds map[string]*delivery.BuilderBuild
|
||||
archives map[string][]byte
|
||||
fetched []string
|
||||
served map[string]map[string]any
|
||||
servedRead []string
|
||||
buildsRead []string
|
||||
}
|
||||
|
||||
func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
b.buildsRead = append(b.buildsRead, module)
|
||||
return b.builds[module+" "+fp], nil
|
||||
}
|
||||
|
||||
func (b *bus) Archive(_ context.Context, url, digest string) ([]byte, error) {
|
||||
b.fetched = append(b.fetched, url)
|
||||
body, ok := b.archives[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("the store holds no such blob")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (b *bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
|
||||
b.servedRead = append(b.servedRead, node+"."+module+":"+strings.Join(keys, ","))
|
||||
out := map[string]any{}
|
||||
for _, k := range keys {
|
||||
if v, ok := b.served[node+"."+module][k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
return b.placements, nil
|
||||
}
|
||||
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
|
||||
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
|
||||
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
|
||||
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
@@ -46,7 +89,17 @@ func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error
|
||||
return b.own, b.ownErr
|
||||
}
|
||||
|
||||
func (b *bus) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
func (b *bus) Credentials(_ context.Context, node string, asConsumer, _ []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
b.asked = append(b.asked, asConsumer...)
|
||||
for _, k := range asConsumer {
|
||||
placed := false
|
||||
for _, m := range b.placements[node].Modules {
|
||||
placed = placed || m.Module == k.ConsumerModule
|
||||
}
|
||||
if !placed {
|
||||
return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node)
|
||||
}
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -68,6 +121,11 @@ func publicKey(t *testing.T) string {
|
||||
// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input
|
||||
// composes; a node reads only its own config values; what cannot be composed is refused, naming it.
|
||||
func TestInput(t *testing.T) {
|
||||
// Issue 1126, the seed's shadow: every refusal named, an old-form module left to the node, and no store reached
|
||||
// saying why (answer_rows_test.go).
|
||||
t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode)
|
||||
t.Run("no store reached says why", noStoreReachedSaysWhy)
|
||||
t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed)
|
||||
ctx := context.Background()
|
||||
xterm, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
|
||||
if err != nil {
|
||||
@@ -160,6 +218,14 @@ func TestInput(t *testing.T) {
|
||||
wj, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's
|
||||
// and the vault's; with none, the controller's alone, as above.
|
||||
signing := mk()
|
||||
signing.vaultAuthority = "the vault's certificate"
|
||||
if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil ||
|
||||
bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" {
|
||||
t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
|
||||
t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
|
||||
}
|
||||
@@ -179,6 +245,219 @@ func TestInput(t *testing.T) {
|
||||
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
|
||||
}
|
||||
|
||||
// Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one
|
||||
// thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
|
||||
// here. Placed puts the same placement over the store's input unchanged.
|
||||
running := make([]delivery.Composed, 0, len(fromBus))
|
||||
for _, c := range fromBus {
|
||||
running = append(running, c)
|
||||
}
|
||||
laptop := mk().placements["laptop"]
|
||||
asIs, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
|
||||
t.Errorf("the candidate equal to the placement: %+v", c)
|
||||
}
|
||||
moved := mk().placements["anchor"]
|
||||
moved.Decided.Endpoint = "anchor.example:51821"
|
||||
changed, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
|
||||
t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name)
|
||||
}
|
||||
if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
|
||||
t.Errorf("nothing runs: %+v", c)
|
||||
}
|
||||
|
||||
// ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not
|
||||
// yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes.
|
||||
keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`)
|
||||
lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
|
||||
// A provider and a consumer of a pair credential on the one node (compose's objects and files).
|
||||
objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}})
|
||||
files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
|
||||
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}})
|
||||
one := func(placed ...string) *bus {
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
for _, m := range placed {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
return &bus{placements: map[string]delivery.Placement{"n1": p},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)},
|
||||
"objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs,
|
||||
delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files},
|
||||
own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}}
|
||||
}
|
||||
oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64),
|
||||
"objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)}
|
||||
runs, _, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runsNow []delivery.Composed
|
||||
for _, c := range runs {
|
||||
runsNow = append(runsNow, c)
|
||||
}
|
||||
objectsRun, _, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var consumerRuns []delivery.Composed
|
||||
for _, c := range objectsRun {
|
||||
consumerRuns = append(consumerRuns, c)
|
||||
}
|
||||
withoutOwn := func(cs []delivery.Composed) []delivery.Composed {
|
||||
var out []delivery.Composed
|
||||
for _, c := range cs {
|
||||
if c.Module != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private
|
||||
// network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the
|
||||
// node's own, each in its place, its module taken off; the rest split by module.
|
||||
tunnelled := func() *bus {
|
||||
b := one("keeper", "lemurs", overlay.Name)
|
||||
p := b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
return b
|
||||
}
|
||||
tunnelledAs := func() map[string]*delivery.Placement {
|
||||
p := tunnelled().placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
}
|
||||
folded := func() []delivery.Composed {
|
||||
b := tunnelled()
|
||||
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reading := in
|
||||
reading.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
reading.Nodes[0].ReadsModules = true
|
||||
d, _, err := compose.ComposeIn(reading, "n1", compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope struct {
|
||||
Resources []map[string]json.RawMessage `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resources []json.RawMessage
|
||||
for _, r := range envelope.Resources {
|
||||
var module string
|
||||
_ = json.Unmarshal(r["module"], &module)
|
||||
if module == "lemurs" || module == overlay.Name {
|
||||
delete(r, "module")
|
||||
}
|
||||
raw, _ := json.Marshal(r)
|
||||
resources = append(resources, raw)
|
||||
}
|
||||
split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []delivery.Composed
|
||||
for _, c := range split {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
|
||||
t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name)
|
||||
}
|
||||
placedAs := func(modules ...string) map[string]*delivery.Placement {
|
||||
p := one(modules...).placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
b *bus
|
||||
given map[string]*delivery.Placement
|
||||
running []delivery.Composed
|
||||
verdict string
|
||||
check func(delivery.CandidateAnswer) bool
|
||||
}{
|
||||
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 3 && a.Head == "h1" && a.Revision == 7 }},
|
||||
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
|
||||
func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
|
||||
"one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool {
|
||||
for _, m := range a.Added {
|
||||
if m.Module == "keeper" {
|
||||
return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}},
|
||||
"a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password")
|
||||
}},
|
||||
// Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an
|
||||
// ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued.
|
||||
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers,
|
||||
func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0
|
||||
}},
|
||||
// And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition.
|
||||
"the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0
|
||||
}},
|
||||
"cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused,
|
||||
func(a delivery.CandidateAnswer) bool { return a.Refused != "" }},
|
||||
// Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want
|
||||
// names and the controller folds into the node's own, are folded there again: the node's own is the same, and
|
||||
// neither is added.
|
||||
"mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(),
|
||||
folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
} {
|
||||
fps := oneFps
|
||||
if name == "mesh-wireguard and an old-form module, folded" {
|
||||
fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
|
||||
}
|
||||
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "h1", 7)
|
||||
if a.Verdict != c.verdict || !c.check(a) {
|
||||
t.Errorf("%s: %+v", name, a)
|
||||
}
|
||||
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
|
||||
t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
|
||||
}
|
||||
}
|
||||
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Placed with the node's own placement changes the input: %v", err)
|
||||
}
|
||||
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
|
||||
t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err)
|
||||
}
|
||||
if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil {
|
||||
t.Error("Placed a node the input holds no node for")
|
||||
}
|
||||
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil {
|
||||
t.Error("a given placement naming another node was taken")
|
||||
}
|
||||
|
||||
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
|
||||
// placement the bus holds, and read back it is the store's input again.
|
||||
for node, placed := range mk().placements {
|
||||
@@ -215,11 +494,193 @@ func TestInput(t *testing.T) {
|
||||
t.Error("laptop's media, which declares an own secret, composed without the vault")
|
||||
}
|
||||
|
||||
// A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with
|
||||
// what it made (issue 1126), on the composing node alone.
|
||||
kept := mk()
|
||||
kept.manifests["old"] = xterm
|
||||
in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", Kept: map[string]string{"xterm": "old"}})
|
||||
if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": parsed}) || in.Nodes[0].Kept != nil {
|
||||
t.Errorf("the want's manifest is kept on the composing node alone: %v, %v", in.Nodes, err)
|
||||
oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` +
|
||||
`"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`)
|
||||
kept.manifests["old"] = oldXterm
|
||||
oldFP := "src2:" + strings.Repeat("9", 64)
|
||||
toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64)
|
||||
kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old",
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
|
||||
parsedOld, err := catalogue.ParseManifestForInputs(oldXterm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}})
|
||||
if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil {
|
||||
t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err)
|
||||
}
|
||||
|
||||
// Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog
|
||||
// c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go
|
||||
// handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's,
|
||||
// ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read
|
||||
// where the node reaches the artifact store, and the composition is the one the controller's take-in gives.
|
||||
resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"serves":{"artifact-store":{"port":5000}}}`)
|
||||
texts := map[string]string{}
|
||||
var packed bytes.Buffer
|
||||
zw := gzip.NewWriter(&packed)
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
|
||||
text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
texts[f] = string(text)
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archiveDigest := delivery.ManifestDigest(packed.Bytes())
|
||||
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
|
||||
"store": "src2:" + strings.Repeat("5", 64)}
|
||||
resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}}
|
||||
clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
|
||||
movedMesh := func(built bool) *bus {
|
||||
b := one()
|
||||
p := b.placements["n1"]
|
||||
p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
|
||||
p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator"
|
||||
b.placements["n1"] = p
|
||||
rec := b.records["n1"]
|
||||
rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"}
|
||||
b.records["n1"] = rec
|
||||
b.modules = map[string]delivery.Module{}
|
||||
b.manifests = map[string][]byte{}
|
||||
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} {
|
||||
b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)}
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
}
|
||||
// The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out.
|
||||
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
|
||||
if built {
|
||||
b.builds = map[string]*delivery.BuilderBuild{
|
||||
"restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
|
||||
"mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
|
||||
"store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
|
||||
}
|
||||
b.archives = map[string][]byte{archiveDigest: packed.Bytes()}
|
||||
}
|
||||
return b
|
||||
}
|
||||
if _, _, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err == nil || !strings.Contains(err.Error(), `restic needs a secret called "repository"`) {
|
||||
t.Errorf("restic composed from the indexer's manifest: %v, want its own secret refused", err)
|
||||
}
|
||||
builtBus := movedMesh(true)
|
||||
fromBuild, _, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps)
|
||||
if err != nil {
|
||||
t.Fatalf("restic composed from its build: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) {
|
||||
t.Errorf("the templates archive was read at %v", builtBus.fetched)
|
||||
}
|
||||
resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"])
|
||||
if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret
|
||||
t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled)
|
||||
}
|
||||
// A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8).
|
||||
unreferenced := movedMesh(true)
|
||||
unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
|
||||
if _, _, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err == nil || !strings.Contains(err.Error(), "the build of restic is done and waits for the reference of tools") {
|
||||
t.Errorf("a done key with an unreferenced artifact: %v", err)
|
||||
}
|
||||
// A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done
|
||||
// build of it is at the trunk's FP2: its build is not read.
|
||||
folding := movedMesh(true)
|
||||
foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientParsed, err := catalogue.ParseManifestForInputs(clientRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) {
|
||||
t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead)
|
||||
}
|
||||
// The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes
|
||||
// from those over the same input.
|
||||
storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for module, built := range map[string][]catalogue.Built{
|
||||
"restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}},
|
||||
"mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}},
|
||||
} {
|
||||
resolved, err := storeIn.Catalogue[module].Resolve(built)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Kept as its bytes and read again, as the controller's store holds a build's manifest.
|
||||
raw, _ := json.Marshal(resolved)
|
||||
if storeIn.Catalogue[module], err = catalogue.ParseManifestForInputs(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
storeIn.OwnSecrets = movedMesh(true).own
|
||||
fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(fromBuild, fromStoreMoved) {
|
||||
gj, _ := json.MarshalIndent(fromBuild, "", " ")
|
||||
wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
|
||||
t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
|
||||
// And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
|
||||
// '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
|
||||
mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` +
|
||||
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
|
||||
withMailer := func(set bool) *bus {
|
||||
b := mk()
|
||||
p := b.placements["anchor"]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"})
|
||||
b.placements["anchor"] = p
|
||||
b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)}
|
||||
b.manifests[delivery.ManifestDigest(mailer)] = mailer
|
||||
if set {
|
||||
b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}}
|
||||
}
|
||||
return b
|
||||
}
|
||||
if _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) {
|
||||
t.Errorf("a provider's served setting nobody sets: %v", err)
|
||||
}
|
||||
served := withMailer(true)
|
||||
if _, _, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil {
|
||||
t.Errorf("laptop with what anchor's mailer serves: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") {
|
||||
t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead)
|
||||
}
|
||||
|
||||
for name, c := range map[string]struct {
|
||||
@@ -227,10 +688,10 @@ func TestInput(t *testing.T) {
|
||||
scope Scope
|
||||
says string
|
||||
}{
|
||||
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
|
||||
"no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
|
||||
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
|
||||
"a kept one unread": {func(b *bus) {}, Scope{Node: "laptop", Kept: map[string]string{"xterm": "gone"}}, "digest gone"},
|
||||
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
|
||||
"no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
|
||||
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
|
||||
"a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"},
|
||||
} {
|
||||
b := mk()
|
||||
c.change(b)
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"module": "mesh-provisioner-client",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-provisioner",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"provisioner-broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-provisioner-client",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-provisioner-client",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-provisioner-client",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "client",
|
||||
"type": "process",
|
||||
"name": "mesh-provisioner-client",
|
||||
"artifact": "client",
|
||||
"run": [
|
||||
"./mesh-provisioner-client"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
|
||||
"MESH_PROVISIONER_STATE": "${dir:state}"
|
||||
},
|
||||
"user": "mesh-provisioner-client",
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "client",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-provisioner-client",
|
||||
"binary": "mesh-provisioner-client"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
|
||||
${backup:lines}
|
||||
@@ -0,0 +1,2 @@
|
||||
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
|
||||
${backup:data}
|
||||
@@ -0,0 +1,6 @@
|
||||
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
|
||||
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
|
||||
daily=${config:keep-daily}
|
||||
weekly=${config:keep-weekly}
|
||||
monthly=${config:keep-monthly}
|
||||
hand=${config:keep-hand-taken-days}
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"module": "restic",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-backup",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"backed-up",
|
||||
"now",
|
||||
"restore"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"backed-up"
|
||||
],
|
||||
"tools": [
|
||||
"backup_health",
|
||||
"backup_delete_retired",
|
||||
"backup_deleted"
|
||||
],
|
||||
"own-secrets": {
|
||||
"repository": "${dir:state}/repository.secret"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "repository",
|
||||
"path": "${dir:repository}",
|
||||
"class": "rebuildable",
|
||||
"backup": "none",
|
||||
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"path": "${dir:state}",
|
||||
"class": "cache",
|
||||
"why": "what the last nights and measurements were; the next night writes it again"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"keep-daily": {
|
||||
"type": "number",
|
||||
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 14
|
||||
},
|
||||
"keep-weekly": {
|
||||
"type": "number",
|
||||
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 8
|
||||
},
|
||||
"keep-monthly": {
|
||||
"type": "number",
|
||||
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 6
|
||||
},
|
||||
"keep-hand-taken-days": {
|
||||
"type": "number",
|
||||
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 7
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "repository",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/backups.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/backups.conf"
|
||||
},
|
||||
{
|
||||
"id": "data-declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/data.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/data.conf"
|
||||
},
|
||||
{
|
||||
"id": "retention",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/retention.conf",
|
||||
"mode": "0644",
|
||||
"trusted": true,
|
||||
"template": "files/retention.conf"
|
||||
},
|
||||
{
|
||||
"id": "tool",
|
||||
"type": "package",
|
||||
"package": "restic"
|
||||
},
|
||||
{
|
||||
"id": "sqlite",
|
||||
"type": "package",
|
||||
"package": "sqlite"
|
||||
},
|
||||
{
|
||||
"id": "night",
|
||||
"type": "process",
|
||||
"name": "restic-night",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"night"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "backup_health",
|
||||
"interval": "60s",
|
||||
"timeout": "20s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "measure",
|
||||
"type": "process",
|
||||
"name": "restic-measure",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"measure"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/restic-backups",
|
||||
"binary": "restic-backups",
|
||||
"loads": [
|
||||
"restic-backups"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named
|
||||
@@ -189,6 +190,16 @@ type Node struct {
|
||||
// Contract is the contract of its node-engine's newest statement (inv.HealthOf); Stated is whether it ever stated one.
|
||||
Contract int
|
||||
Stated bool
|
||||
// InOwn is the modules it runs inside its own composition (`<node>.node`), not by a composition of their own, since
|
||||
// no fingerprint of the new form names them where it runs them (novox/hq issue 1126): Compositions folds their
|
||||
// resources into the node's own, as the controller's composed writer does, unless fps gives one of the new form.
|
||||
// The private network's module (overlay.Name), which nobody builds, is folded whether or not it is named here.
|
||||
InOwn map[string]bool
|
||||
// RunsOwn is the node's own composition (`<node>.node`) as it runs, when the node composes itself: a module folded
|
||||
// there (InOwn) whose manifest names a build (NamesBuild) runs a build of the old form only the controller holds,
|
||||
// so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each
|
||||
// after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before.
|
||||
RunsOwn *delivery.Composed
|
||||
}
|
||||
|
||||
// Place is where a node is, as its placement says (inv.Overlay).
|
||||
|
||||
@@ -24,6 +24,9 @@ type composer struct {
|
||||
in Input
|
||||
said []string
|
||||
seen map[string]bool
|
||||
// unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
|
||||
// composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
|
||||
unhostable []string
|
||||
}
|
||||
|
||||
func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
|
||||
|
||||
@@ -325,6 +325,13 @@ func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string
|
||||
return "", "", false, nil
|
||||
}
|
||||
|
||||
// ArtifactStoreAddress is the artifact store as node reaches it, by the rule its composition routes every kept reference
|
||||
// with (artifactStoreAddress): empty when node reaches none. A node composing its own modules reads a templates archive
|
||||
// there (issue 1126).
|
||||
func ArtifactStoreAddress(in Input, node string) (string, error) {
|
||||
return newComposer(in).artifactStoreAddress(node)
|
||||
}
|
||||
|
||||
// artifactStoreAddress is the artifact store as `forNode` reaches it: `<node>.internal:<port>` over the private network, or
|
||||
// `127.0.0.1:<port>` for the node that holds the store itself when nothing is on the network yet, and empty for any other
|
||||
// (novox/hq 04-ISSUES/102).
|
||||
|
||||
+15
-1
@@ -3,6 +3,7 @@ package compose
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
@@ -203,7 +204,7 @@ func (c *composer) resolution(nodeName string) (catalogue.Resolution, error) {
|
||||
return catalogue.Resolution{}, err
|
||||
}
|
||||
shelf := c.shelfFor(node)
|
||||
world, _, _, err := c.theRestOfTheMesh(nodeName)
|
||||
world, _, left, err := c.theRestOfTheMesh(nodeName)
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, err
|
||||
}
|
||||
@@ -226,6 +227,19 @@ func (c *composer) resolution(nodeName string) (catalogue.Resolution, error) {
|
||||
Account: node.Account, AccountHome: node.AccountHome,
|
||||
AgentAccount: node.AgentAccount, AgentAccountHome: node.AgentAccountHome}, world)
|
||||
if err != nil {
|
||||
// What another node's set would have offered is missing when that set does not resolve here: said with the
|
||||
// refusal, so "nothing provides" is read beside why (issue 1126, probe #532).
|
||||
if len(left) > 0 {
|
||||
var why []string
|
||||
for _, l := range left {
|
||||
why = append(why, l.String())
|
||||
}
|
||||
err = fmt.Errorf("%w\n\nand %d node(s) offer nothing here, as they could not be resolved: %s", err, len(left), strings.Join(why, "; "))
|
||||
}
|
||||
if len(c.unhostable) > 0 {
|
||||
err = fmt.Errorf("%w\n\nand these modules offer nothing here, as their node cannot run them in this composition: %s",
|
||||
err, strings.Join(c.unhostable, "; "))
|
||||
}
|
||||
// The node's own set does not compose: the only failure a mesh-wide gatherer may pass over.
|
||||
return catalogue.Resolution{}, notResolvable{err}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
@@ -100,6 +101,7 @@ func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []
|
||||
var left []LeftOut
|
||||
offered := map[string][]catalogue.Provider{}
|
||||
var firstHeld []catalogue.Held
|
||||
c.unhostable = nil
|
||||
for _, o := range others {
|
||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
|
||||
if err != nil {
|
||||
@@ -107,6 +109,9 @@ func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []
|
||||
left = append(left, LeftOut{Node: o.node.Name, Err: err})
|
||||
continue
|
||||
}
|
||||
for _, u := range got.Unhostable {
|
||||
c.unhostable = append(c.unhostable, fmt.Sprintf("%s on %s lacks %s", u.Module, o.node.Name, strings.Join(u.Missing, ", ")))
|
||||
}
|
||||
firstHeld = append(firstHeld, got.Claims...)
|
||||
for _, m := range got.Modules {
|
||||
for _, name := range m.OffersAt(catalogue.ScopeMesh) {
|
||||
|
||||
+14
-4
@@ -17,6 +17,8 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/sdkversion"
|
||||
)
|
||||
|
||||
@@ -64,10 +66,15 @@ type Request struct {
|
||||
}
|
||||
|
||||
// Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs.
|
||||
//
|
||||
// Placements are, by node, the placement the case composes that node with in place of what Input says of it: a change's
|
||||
// own placement files, so a check composes a catalogue pull request's placement (issue 1126, frombus.Placed); a null
|
||||
// placement leaves nothing placed on the node. A case without them composes Input's.
|
||||
type Case struct {
|
||||
Name string `json:"name"`
|
||||
Manifests map[string]Manifest `json:"manifests"`
|
||||
Kept map[string]map[string]Manifest `json:"kept,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Manifests map[string]Manifest `json:"manifests"`
|
||||
Kept map[string]map[string]Manifest `json:"kept,omitempty"`
|
||||
Placements map[string]*delivery.Placement `json:"placements,omitempty"`
|
||||
}
|
||||
|
||||
// Manifest is a module's manifest as its repository holds it (Raw, the bytes), or the digest the delivery indexer keeps
|
||||
@@ -251,7 +258,10 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) {
|
||||
if err != nil {
|
||||
return CaseVerdict{}, err
|
||||
}
|
||||
in := r.Input
|
||||
in, err := frombus.Placed(r.Input, c.Placements)
|
||||
if err != nil {
|
||||
return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err)
|
||||
}
|
||||
in.Catalogue = shelf
|
||||
in.Nodes = slices.Clone(in.Nodes)
|
||||
in.Holdings = slices.Clone(in.Holdings)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}`
|
||||
@@ -100,6 +101,19 @@ func TestARequestItCannotRunIsRefused(t *testing.T) {
|
||||
if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil {
|
||||
t.Error("an unknown depth runs")
|
||||
}
|
||||
// A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not.
|
||||
placed := Request{V: Version, Input: compose.Input{Nodes: []compose.Node{{Name: "a"}}}, Machines: []string{"a"}, Depth: DepthResolve,
|
||||
Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}},
|
||||
{Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}},
|
||||
Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}}
|
||||
if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes ||
|
||||
!strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") {
|
||||
t.Errorf("a case composes its own placement: %+v, %v", v, err)
|
||||
}
|
||||
placed.Cases[1].Placements = map[string]*delivery.Placement{"z": {Node: "z"}}
|
||||
if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") {
|
||||
t.Errorf("a case placing a node the input lacks: %v", err)
|
||||
}
|
||||
v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}})
|
||||
if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" {
|
||||
t.Fatalf("a request with no machines: %+v, %v", v, err)
|
||||
|
||||
@@ -452,6 +452,12 @@ func ParseCompositionKey(key string) (group, headsHash string, err error) {
|
||||
return group, headsHash, nil
|
||||
}
|
||||
|
||||
// CompositionUnanswered is the Words of a composition that failed only because a node placed at the head or on the
|
||||
// trunk did not answer the group's placement candidate within its bound (novox/hq ADR 0559 §5): the checker takes a
|
||||
// later passing composition of the same heads in without a new check, since the failure was a part of the mesh not
|
||||
// heard, not the change (ADR 0548 §1).
|
||||
const CompositionUnanswered = "did not answer the placement candidate within its bound; judged again when it answers"
|
||||
|
||||
// CompositionVerdict is the value of a composition key: the group and its heads hash, the State (pass, fail or waits),
|
||||
// the Words of a refusal or of what it waits for, the mesh-sdk commit it composed with (the assigner's own, or the
|
||||
// group's mesh-sdk member's head, ADR 0482 clause 3), the machines composed, the first machine that failed, the
|
||||
|
||||
@@ -298,6 +298,25 @@ func TestControllerGrant(t *testing.T) {
|
||||
if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil {
|
||||
t.Errorf("a grant naming an empty account was accepted")
|
||||
}
|
||||
// The grants part's keys are the controller's: the same key round-trips; the source is the part's bucket only for a
|
||||
// handed-away lease (issue 774).
|
||||
if pk, err := PartGrantKey("mesh-delivery-assigner", fp); err != nil || pk != key {
|
||||
t.Errorf("PartGrantKey = %q, %v, want the controller's %q", pk, err, key)
|
||||
}
|
||||
if m, f, err := ParsePartGrantKey(key); err != nil || m != "mesh-delivery-assigner" || f != fp {
|
||||
t.Errorf("ParsePartGrantKey = %q, %q, %v", m, f, err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
away bool
|
||||
bucket, read string
|
||||
}{
|
||||
{false, "mesh-controller_grants", "mesh-controller.grants"},
|
||||
{true, "mesh-grants_grants", "mesh-grants.grants"},
|
||||
} {
|
||||
if b, r := GrantSource(c.away); b != c.bucket || r != c.read {
|
||||
t.Errorf("GrantSource(%v) = %q, %q, want %q, %q", c.away, b, r, c.bucket, c.read)
|
||||
}
|
||||
}
|
||||
g := ControllerGrant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}}
|
||||
for _, c := range []struct {
|
||||
g ControllerGrant
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package buildrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
|
||||
// A build module writes <node>.<language> of its toolchains state on every node it runs on, and nothing deletes the key
|
||||
// when the module leaves a node: no part writes another module's state, and the module is gone. A report of a node the
|
||||
// build module no longer runs on is therefore no builder's (issue 1940: mesh-build-node taken off ace on 2026-10-23 left
|
||||
// ace.typescript at v26.12.0, and every Node fingerprint said "builders disagree").
|
||||
//
|
||||
// ToolchainGate stands between the toolchains buckets and what takes their entries in (the indexer's PutToolchain, a
|
||||
// ToolchainSet): an entry is passed on only while the catalogue's placement (the indexer's state `placement`, read by
|
||||
// delivery.Placed; ADR 0549 §3) places its build module on its node, and only once it was written since that placement;
|
||||
// it is passed on as deleted otherwise, said once, and passed on when its node writes it again. A report written before
|
||||
// the placement is one an earlier run of the module left (issue 1940, 2026-10-25 20:56: mesh-build-node put on ace again
|
||||
// brought ace.typescript v26.12.0 of 2026-10-23 back into the agreement before the module ran there). Until the
|
||||
// placement is settled, and while no node's placement is held (before the first placement file reaches the indexer, or
|
||||
// where the state cannot be read), every entry is passed on. Builders that run and report different versions still
|
||||
// disagree: that refusal is the guard of novox/hq ADR 0465, and it stays.
|
||||
//
|
||||
// This replaces the copies of the gate the indexer, the builder and the checker each kept over the controller's
|
||||
// assignments (issue 1940's subtask, issue 774: those go with the controller's lease, ADR 0549 §6).
|
||||
type ToolchainGate struct {
|
||||
mu sync.Mutex
|
||||
modules map[string]bool // the build modules whose toolchains pass the gate
|
||||
forward func(module, key string, value []byte, deleted bool) bool // answers whether it changed what it holds
|
||||
logf func(format string, a ...any)
|
||||
placed *delivery.Placed
|
||||
known bool // the placement is settled
|
||||
raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it
|
||||
left map[string]string // <module>/<key> -> why it was said left out, so each reason is said once
|
||||
}
|
||||
|
||||
// NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what
|
||||
// it leaves out.
|
||||
func NewToolchainGate(modules []string, forward func(module, key string, value []byte, deleted bool) bool, logf func(string, ...any)) *ToolchainGate {
|
||||
g := &ToolchainGate{modules: map[string]bool{}, forward: forward, logf: logf, placed: delivery.NewPlaced(),
|
||||
raw: map[string]map[string][]byte{}, left: map[string]string{}}
|
||||
for _, m := range modules {
|
||||
g.modules[m] = true
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Toolchain takes one change of a build module's toolchains bucket in and answers whether what it passed on changed.
|
||||
func (g *ToolchainGate) Toolchain(module, key string, value []byte, deleted bool) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.raw[module] == nil {
|
||||
g.raw[module] = map[string][]byte{}
|
||||
}
|
||||
if deleted {
|
||||
delete(g.raw[module], key)
|
||||
} else {
|
||||
g.raw[module][key] = value
|
||||
}
|
||||
return g.pass(module, key)
|
||||
}
|
||||
|
||||
// Placement takes one revision of the indexer's placement in (at is when the bus wrote it) and answers whether what it
|
||||
// passed on changed. A value that does not read is said, and places nothing on its node.
|
||||
func (g *ToolchainGate) Placement(key string, value []byte, deleted bool, at time.Time) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
was := g.placed.Seeded()
|
||||
node, changed, err := g.placed.Put(key, value, deleted, at)
|
||||
if err != nil {
|
||||
g.logf("%s: %v (issue 1940)", delivery.PlacementBucket, err)
|
||||
}
|
||||
if !g.known {
|
||||
return false
|
||||
}
|
||||
if was != g.placed.Seeded() {
|
||||
// The first placement came, or the last went: every report's standing changed.
|
||||
return g.all()
|
||||
}
|
||||
return g.again(node, changed)
|
||||
}
|
||||
|
||||
// Settled says the placement's history is read: from now on only the reports of placed nodes pass, once a node's
|
||||
// placement is held. It answers whether what it passed on changed.
|
||||
func (g *ToolchainGate) Settled() bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.known = true
|
||||
return g.all()
|
||||
}
|
||||
|
||||
// all passes on every report again. The caller holds mu.
|
||||
func (g *ToolchainGate) all() bool {
|
||||
changed := false
|
||||
for _, module := range slices.Sorted(maps.Keys(g.raw)) {
|
||||
for _, k := range slices.Sorted(maps.Keys(g.raw[module])) {
|
||||
changed = g.pass(module, k) || changed
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// again passes on every report of node by a build module among modules. The caller holds mu.
|
||||
func (g *ToolchainGate) again(node string, modules []string) bool {
|
||||
changed := false
|
||||
for _, module := range modules {
|
||||
if !g.modules[module] {
|
||||
continue
|
||||
}
|
||||
for _, k := range slices.Sorted(maps.Keys(g.raw[module])) {
|
||||
if n, _, err := delivery.ParseToolchainKey(k); err == nil && n == node {
|
||||
changed = g.pass(module, k) || changed
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// pass hands one key on as it stands: its value when its node is placed and wrote it since (or the placement is not
|
||||
// known), deleted otherwise. The caller holds mu.
|
||||
func (g *ToolchainGate) pass(module, key string) bool {
|
||||
value, held := g.raw[module][key]
|
||||
if !held {
|
||||
return g.forward(module, key, nil, true)
|
||||
}
|
||||
node, _, err := delivery.ParseToolchainKey(key)
|
||||
why := ""
|
||||
if err == nil && g.known && g.placed.Seeded() {
|
||||
why = g.leftOut(module, node, value)
|
||||
}
|
||||
if why == "" {
|
||||
delete(g.left, module+"/"+key)
|
||||
return g.forward(module, key, value, false)
|
||||
}
|
||||
if g.left[module+"/"+key] != why {
|
||||
g.left[module+"/"+key] = why
|
||||
g.logf("%s.%s is left out of the agreed toolchain: %s (issue 1940)", delivery.ToolchainsBucket(module), key, why)
|
||||
}
|
||||
return g.forward(module, key, nil, true)
|
||||
}
|
||||
|
||||
// leftOut is why a node's report is no builder's, or empty when it counts: the catalogue does not place the module on its
|
||||
// node, or the report was written before it did. A value that is no toolchain is passed on for its reader to refuse.
|
||||
// The caller holds mu.
|
||||
func (g *ToolchainGate) leftOut(module, node string, value []byte) string {
|
||||
since, placed := g.placed.Since(node, module)
|
||||
if !placed {
|
||||
return fmt.Sprintf("the catalogue does not place %s on %s, so it is no builder", module, node)
|
||||
}
|
||||
tc, err := delivery.DecodeToolchain(value)
|
||||
if err == nil && tc.At.Before(since) {
|
||||
return fmt.Sprintf("it was written at %s, before %s was placed on %s at %s: it counts once %s writes it again",
|
||||
tc.At.UTC().Format(time.RFC3339), module, node, since.UTC().Format(time.RFC3339), node)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FollowPlacement reads the indexer's placement with its history into gate, says it settled, and follows it; changed
|
||||
// is told whenever what the gate passes on changed. Where the state cannot be read or watched (not there, or this part
|
||||
// may not read it) it says so and the gate passes every report on: not fatal, since an indexer, builder or checker that
|
||||
// stops for it stops every delivery (issue 1945). An error of the watch after that ends on the channel.
|
||||
func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *ToolchainGate, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) {
|
||||
errs := make(chan error, 1)
|
||||
kv, err := js.KeyValue(ctx, delivery.PlacementBucket)
|
||||
if err != nil {
|
||||
logf("the state %s cannot be read, so every node's toolchain report counts, a build node's the catalogue does not place too (issue 1940): %v", delivery.PlacementBucket, err)
|
||||
return errs, nil
|
||||
}
|
||||
w, err := kv.WatchAll(ctx, jetstream.IncludeHistory())
|
||||
if err != nil {
|
||||
logf("the state %s cannot be watched, so every node's toolchain report counts, a build node's the catalogue does not place too (issue 1940): %v", delivery.PlacementBucket, err)
|
||||
return errs, nil
|
||||
}
|
||||
put := func(e jetstream.KeyValueEntry) bool {
|
||||
return gate.Placement(e.Key(), e.Value(), e.Operation() != jetstream.KeyValuePut, e.Created())
|
||||
}
|
||||
for settled := false; !settled; {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = w.Stop()
|
||||
return errs, ctx.Err()
|
||||
case e, ok := <-w.Updates():
|
||||
if !ok {
|
||||
return errs, errors.New("the watch of " + delivery.PlacementBucket + " ended before it settled")
|
||||
}
|
||||
if e == nil {
|
||||
settled = true
|
||||
continue
|
||||
}
|
||||
put(e)
|
||||
}
|
||||
}
|
||||
if gate.Settled() {
|
||||
changed(ctx)
|
||||
}
|
||||
go func() {
|
||||
defer func() { _ = w.Stop() }()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errs <- ctx.Err()
|
||||
return
|
||||
case e, ok := <-w.Updates():
|
||||
if !ok {
|
||||
errs <- errors.New("the watch of " + delivery.PlacementBucket + " ended")
|
||||
return
|
||||
}
|
||||
if e != nil && put(e) {
|
||||
changed(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return errs, nil
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package buildrun
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func gateReport(t *testing.T, node, version string, at time.Time) (string, []byte) {
|
||||
t.Helper()
|
||||
key, err := delivery.ToolchainKey(node, "typescript")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "typescript", Version: version, Recipe: "f59571cf4be75adf", At: at})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, value
|
||||
}
|
||||
|
||||
// placing is a value of the state `placement` placing modules on node.
|
||||
func placing(t *testing.T, node string, modules ...string) (string, []byte) {
|
||||
t.Helper()
|
||||
p := delivery.Placement{Node: node}
|
||||
for _, m := range modules {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
key, err := delivery.PlacementKey(node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := delivery.EncodePlacement(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, value
|
||||
}
|
||||
|
||||
func agreedOf(set *ToolchainSet) (string, string) {
|
||||
tc, none := delivery.AgreedToolchain(set.Entries("typescript"))
|
||||
return tc.Version, none
|
||||
}
|
||||
|
||||
// Issue 1940, the gate over the catalogue's placement (issue 774, ADR 0549 §3): a build node's report counts only while
|
||||
// the placement places its build module on its node, and only once written since; before the placement is settled, and
|
||||
// while no node's placement is held, every report counts. The live cases: mesh-build-node taken off ace left
|
||||
// ace.typescript at v26.12.0 against v26.12.1 on the three others; put on ace again, its report of 2026-10-23 06:29 came
|
||||
// back before the module ran there.
|
||||
func TestToolchainGate(t *testing.T) {
|
||||
old := time.Date(2026, 10, 23, 6, 29, 18, 0, time.UTC) // ace's report from its earlier run
|
||||
placed := time.Date(2026, 10, 25, 19, 56, 45, 0, time.UTC) // mesh-build-node placed on ace again
|
||||
three := []string{"g14", "novox", "shanks"}
|
||||
start := func(t *testing.T, seeded bool) (*ToolchainGate, *ToolchainSet, *[]string) {
|
||||
set := &ToolchainSet{}
|
||||
said := &[]string{}
|
||||
gate := NewToolchainGate([]string{GoBuildModule, NodeBuildModule}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) })
|
||||
for _, node := range three {
|
||||
if seeded {
|
||||
key, value := placing(t, node, NodeBuildModule)
|
||||
gate.Placement(key, value, false, old.Add(-time.Hour))
|
||||
}
|
||||
key, value := gateReport(t, node, "v26.12.1", placed.Add(time.Minute))
|
||||
gate.Toolchain(NodeBuildModule, key, value, false)
|
||||
}
|
||||
return gate, set, said
|
||||
}
|
||||
|
||||
t.Run("not placed on ace: its report is no builder's", func(t *testing.T) {
|
||||
gate, set, said := start(t, true)
|
||||
key, value := gateReport(t, "ace", "v26.12.0", old)
|
||||
gate.Toolchain(NodeBuildModule, key, value, false)
|
||||
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree") {
|
||||
t.Fatalf("before the placement is settled every report counts; got %q", none)
|
||||
}
|
||||
k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter
|
||||
gate.Placement(k, v, false, old)
|
||||
if !gate.Settled() {
|
||||
t.Fatal("settling left ace's report out, so what was passed on changed")
|
||||
}
|
||||
if v, none := agreedOf(set); v != "v26.12.1" || none != "" {
|
||||
t.Fatalf("the placed nodes agree on v26.12.1; got %q, %q", v, none)
|
||||
}
|
||||
if len(*said) != 1 || !strings.Contains((*said)[0], "does not place mesh-build-node on ace") {
|
||||
t.Fatalf("ace's report is said left out once; said %q", *said)
|
||||
}
|
||||
if gate.Toolchain(NodeBuildModule, key, value, false) {
|
||||
t.Fatal("a report of a node not placed changed what was passed on")
|
||||
}
|
||||
// Placed on ace with a report written since: it counts, and the builders really disagree.
|
||||
k, v = placing(t, "ace", GoBuildModule, NodeBuildModule)
|
||||
gate.Placement(k, v, false, old.Add(-time.Minute))
|
||||
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree: v26.12.0 recipe f59571cf4be75adf on ace") {
|
||||
t.Fatalf("builders that run different versions still disagree; got %q", none)
|
||||
}
|
||||
// Taken off ace again (its key deleted): its report goes again.
|
||||
if !gate.Placement(k, nil, true, placed) {
|
||||
t.Fatal("taking ace's placement away passed nothing on")
|
||||
}
|
||||
if v, none := agreedOf(set); v != "v26.12.1" || none != "" {
|
||||
t.Fatalf("got %q, %q", v, none)
|
||||
}
|
||||
// A report deleted from the bucket goes whether its node is placed or not.
|
||||
key, _ = gateReport(t, "g14", "v26.12.1", placed)
|
||||
if !gate.Toolchain(NodeBuildModule, key, nil, true) || len(set.Entries("typescript")) != 2 {
|
||||
t.Fatalf("deleting g14's report: %d entries left, want novox and shanks", len(set.Entries("typescript")))
|
||||
}
|
||||
})
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
at time.Time // when ace wrote its report
|
||||
counts bool
|
||||
}{
|
||||
{"a report left before the placement", old, false},
|
||||
{"a report written since the placement", placed.Add(30 * time.Second), true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gate, set, said := start(t, true)
|
||||
k, v := placing(t, "ace") // ace's key, first heard placing nothing
|
||||
gate.Placement(k, v, false, old.Add(-time.Hour))
|
||||
gate.Settled()
|
||||
k, v = placing(t, "ace", NodeBuildModule)
|
||||
if gate.Placement(k, v, false, placed) {
|
||||
t.Fatal("a placement with no report passed something on")
|
||||
}
|
||||
key, value := gateReport(t, "ace", "v26.12.0", c.at)
|
||||
if got := gate.Toolchain(NodeBuildModule, key, value, false); got != c.counts {
|
||||
t.Fatalf("ace's report passed on: %v, want %v", got, c.counts)
|
||||
}
|
||||
// ace's placement written again with another module beside: its standing does not move, nothing is said again.
|
||||
k, v = placing(t, "ace", GoBuildModule, NodeBuildModule)
|
||||
if gate.Placement(k, v, false, placed.Add(time.Minute)) {
|
||||
t.Fatal("the same placement of mesh-build-node changed what was passed on")
|
||||
}
|
||||
if c.counts {
|
||||
return
|
||||
}
|
||||
if v, none := agreedOf(set); v != "v26.12.1" || none != "" {
|
||||
t.Fatalf("ace's left report is no builder's; got %q, %q", v, none)
|
||||
}
|
||||
if len(*said) != 1 || !strings.Contains((*said)[0], "before mesh-build-node was placed on ace at 2026-10-25T19:56:45Z") {
|
||||
t.Fatalf("said %q, want once why ace's report is left out", *said)
|
||||
}
|
||||
key, value = gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute))
|
||||
if !gate.Toolchain(NodeBuildModule, key, value, false) || len(set.Entries("typescript")) != 4 {
|
||||
t.Fatal("ace's new report was not passed on")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("no placement held: every report counts", func(t *testing.T) {
|
||||
gate, set, _ := start(t, false)
|
||||
key, value := gateReport(t, "ace", "v26.12.0", old)
|
||||
gate.Toolchain(NodeBuildModule, key, value, false)
|
||||
gate.Settled()
|
||||
if n := len(set.Entries("typescript")); n != 4 {
|
||||
t.Fatalf("before the first placement file every report counts; got %d", n)
|
||||
}
|
||||
k, v := placing(t, "g14", NodeBuildModule)
|
||||
if !gate.Placement(k, v, false, old.Add(-time.Hour)) || len(set.Entries("typescript")) != 1 {
|
||||
t.Fatalf("the first placement leaves every node it does not place out; got %d", len(set.Entries("typescript")))
|
||||
}
|
||||
if !gate.Placement(k, nil, true, placed) || len(set.Entries("typescript")) != 4 {
|
||||
t.Fatal("the last placement gone: every report counts again")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the history: a module in the first revision heard of a key stands since before it", func(t *testing.T) {
|
||||
p := delivery.NewPlaced()
|
||||
k, v := placing(t, "ace", NodeBuildModule)
|
||||
p.Put(k, v, false, old)
|
||||
k2, v2 := placing(t, "g14")
|
||||
p.Put(k2, v2, false, old)
|
||||
k2, v2 = placing(t, "g14", NodeBuildModule)
|
||||
if _, changed, _ := p.Put(k2, v2, false, placed); strings.Join(changed, ",") != NodeBuildModule {
|
||||
t.Errorf("a module placed says changed %v", changed)
|
||||
}
|
||||
if _, _, err := p.Put("placement.novox", []byte("not json"), false, old); err == nil {
|
||||
t.Error("a value that does not read was not said")
|
||||
}
|
||||
if s, ok := p.Since("ace", NodeBuildModule); !ok || !s.IsZero() {
|
||||
t.Errorf("ace's module stands since %v, %v; want the zero time", s, ok)
|
||||
}
|
||||
if s, ok := p.Since("g14", NodeBuildModule); !ok || !s.Equal(placed) {
|
||||
t.Errorf("g14's module stands since %v, %v; want the revision that placed it", s, ok)
|
||||
}
|
||||
if got := p.PlacedNodes(NodeBuildModule); strings.Join(got, ",") != "ace,g14" || len(p.ByNode()) != 2 || !p.Seeded() {
|
||||
t.Errorf("PlacedNodes = %v, ByNode = %v", got, p.ByNode())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a placement the bus refuses is said, and the part goes on", func(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
logger := log.New(&logs, "", 0)
|
||||
refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_placement")
|
||||
gate, _, _ := start(t, false)
|
||||
for _, bus := range []refusingBus{{err: refused}, {refuseOpen: true, err: refused}} {
|
||||
if _, err := FollowPlacement(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil {
|
||||
t.Errorf("a refused placement stopped the start: %v", err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs.String(), "cannot be watched") || !strings.Contains(logs.String(), "cannot be read") {
|
||||
t.Errorf("the refusals were not said: %q", logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// refusingBus is a bus that refuses: every open (refuseOpen), or every watch of a bucket it opens, with err.
|
||||
type refusingBus struct {
|
||||
refuseOpen bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (r refusingBus) KeyValue(context.Context, string) (jetstream.KeyValue, error) {
|
||||
if r.refuseOpen {
|
||||
return nil, r.err
|
||||
}
|
||||
return refusedWatch{err: r.err}, nil
|
||||
}
|
||||
|
||||
type refusedWatch struct {
|
||||
jetstream.KeyValue
|
||||
err error
|
||||
}
|
||||
|
||||
func (r refusedWatch) WatchAll(context.Context, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) {
|
||||
return nil, r.err
|
||||
}
|
||||
@@ -35,10 +35,8 @@ func TestToolchainSetKeepsEachNodesEntryByLanguageAndSaysWhenOneChanged(t *testi
|
||||
if !s.Put(GoBuildModule, "ace.go", nil, true) || len(s.Entries("go")) != 1 {
|
||||
t.Error("a delete did not remove the node's entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolchainSetLeavesOutWhatIsNotAToolchainOfTheModulesLanguage(t *testing.T) { // 1607/AC6
|
||||
var s ToolchainSet
|
||||
// What is not a toolchain of the module's language is left out.
|
||||
s = ToolchainSet{}
|
||||
if s.Put(GoBuildModule, "novox.typescript", tcValue(t, "typescript", "x"), false) {
|
||||
t.Error("a typescript toolchain in the go build module's bucket counted")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Comparison is what CompareComposed finds, module by module: the same, differing, added by what was composed (not run
|
||||
// now) and removed by it (run now, not composed). Each list is sorted by module.
|
||||
type Comparison struct {
|
||||
Same []string
|
||||
Differs []ModuleDiffers
|
||||
Added []ModuleAdded
|
||||
Removed []string
|
||||
}
|
||||
|
||||
// CompareComposed compares the compositions a node composed (by key, as compose.Compositions answers them) with the ones
|
||||
// it runs, as its agent would (ADR 0559 §4: a node's answer to a placement candidate). A module is the same when its
|
||||
// composition's digest is the one it runs (ComposedDigestOf, the signature left out), so the agent would apply nothing
|
||||
// of it; it differs when the digests differ (both named); it is added when composed and not run, removed when run and
|
||||
// not composed. The node's own composition is named HaveNodeWord. The bus's user list (catalogue.BusUsersID) is left out
|
||||
// on both sides: the bus node follows it alone and never applies it from a composition it made (ADR 0547 §2, §8). A
|
||||
// difference names the first resource, by its id, whose content differs or that one side alone holds; when every
|
||||
// resource is equal it says what else differs (their order, the files filled, the data, the node's own parts, a file
|
||||
// carried by digest).
|
||||
func CompareComposed(composed map[string]Composed, running []Composed) Comparison {
|
||||
return CompareComposedWith(composed, running, nil)
|
||||
}
|
||||
|
||||
// CompareComposedWith is CompareComposed showing, for each module that differs, every differing resource with both
|
||||
// bodies masked (MaskedPair); sameValue, when given, says whether two sealed values open to the same value (the node
|
||||
// opens them with its own key), so a value sealed again reads as such.
|
||||
func CompareComposedWith(composed map[string]Composed, running []Composed, sameValue func(a, b string) (same, known bool)) Comparison {
|
||||
name := func(c Composed) string {
|
||||
if c.Module == "" {
|
||||
return HaveNodeWord
|
||||
}
|
||||
return c.Module
|
||||
}
|
||||
ours := map[string]Composed{}
|
||||
for _, c := range composed {
|
||||
ours[name(c)] = c
|
||||
}
|
||||
theirs := map[string]Composed{}
|
||||
for _, c := range running {
|
||||
theirs[name(c)] = c
|
||||
}
|
||||
modules := map[string]bool{}
|
||||
for m := range ours {
|
||||
modules[m] = true
|
||||
}
|
||||
for m := range theirs {
|
||||
modules[m] = true
|
||||
}
|
||||
var out Comparison
|
||||
for _, m := range sortedKeys(modules) {
|
||||
a, composedHere := ours[m]
|
||||
b, runs := theirs[m]
|
||||
switch {
|
||||
case !runs:
|
||||
dig, _ := ComposedDigestOf(withoutBusUsers(a))
|
||||
out.Added = append(out.Added, ModuleAdded{Module: m, Composed: dig})
|
||||
case !composedHere:
|
||||
out.Removed = append(out.Removed, m)
|
||||
default:
|
||||
if d, ok := compareOne(m, a, b, sameValue); ok {
|
||||
out.Same = append(out.Same, m)
|
||||
} else {
|
||||
out.Differs = append(out.Differs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compareOne is one module's comparison: true when the digests agree, else what differs first.
|
||||
func compareOne(module string, composed, running Composed, sameValue func(a, b string) (bool, bool)) (ModuleDiffers, bool) {
|
||||
composed, running = withoutBusUsers(composed), withoutBusUsers(running)
|
||||
dc, errC := ComposedDigestOf(composed)
|
||||
dr, errR := ComposedDigestOf(running)
|
||||
if errC != nil {
|
||||
return ModuleDiffers{Module: module, Running: dr, Why: "the composition has no digest: " + errC.Error()}, false
|
||||
}
|
||||
if errR == nil && dc == dr {
|
||||
return ModuleDiffers{}, true
|
||||
}
|
||||
differs := func(resource, why string) (ModuleDiffers, bool) {
|
||||
return ModuleDiffers{Module: module, Composed: dc, Running: dr, Resource: resource, Why: why}, false
|
||||
}
|
||||
// Words for the difference: resource by resource, each side with its large files by digest, so a file carried whole
|
||||
// and the same file carried by digest read as one.
|
||||
a, _, errA := composed.ByDigest()
|
||||
b, _, errB := running.ByDigest()
|
||||
if errA != nil || errB != nil {
|
||||
return differs("", "the compositions differ and cannot be read resource by resource")
|
||||
}
|
||||
ra, rb := resourcesByID(a.Resources), resourcesByID(b.Resources)
|
||||
ids := map[string]bool{}
|
||||
for id := range ra {
|
||||
ids[id] = true
|
||||
}
|
||||
for id := range rb {
|
||||
ids[id] = true
|
||||
}
|
||||
var first ModuleDiffers
|
||||
found := false
|
||||
var bodies []ResourceBodies
|
||||
for _, id := range sortedKeys(ids) {
|
||||
x, inA := ra[id]
|
||||
y, inB := rb[id]
|
||||
why := ""
|
||||
switch {
|
||||
case !inB:
|
||||
why = "composed, and not in what runs"
|
||||
case !inA:
|
||||
why = "in what runs, and not composed"
|
||||
case !bytes.Equal(x, y):
|
||||
why = "the resource differs"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
first, _ = differs(id, why)
|
||||
found = true
|
||||
}
|
||||
if len(bodies) < MaxBodies {
|
||||
c, r := MaskedPair(x, y, sameValue)
|
||||
bodies = append(bodies, ResourceBodies{ID: id, Why: why, Composed: cut(c), Running: cut(r)})
|
||||
}
|
||||
}
|
||||
if found {
|
||||
first.Bodies = bodies
|
||||
return first, false
|
||||
}
|
||||
switch {
|
||||
case !jsonEqual(a.Filled, b.Filled):
|
||||
return differs("", "the files the provisioner fills differ")
|
||||
case a.Data != b.Data:
|
||||
return differs("", "whether it declares data differs")
|
||||
case !jsonEqual(a.Own, b.Own):
|
||||
return differs("", "the node's own parts differ")
|
||||
case a.FP != b.FP:
|
||||
return differs("", "the fingerprint differs")
|
||||
}
|
||||
if dA, err := ComposedDigestOf(a); err == nil {
|
||||
if dB, err := ComposedDigestOf(b); err == nil && dA == dB {
|
||||
return differs("", "the same content, a large file carried whole on one side and by digest on the other")
|
||||
}
|
||||
}
|
||||
return differs("", "the same resources in another order")
|
||||
}
|
||||
|
||||
// withoutBusUsers is c without the bus's user list resource (its id `bus-users`, or `<module>.bus-users`).
|
||||
func withoutBusUsers(c Composed) Composed {
|
||||
out := c
|
||||
out.Resources = nil
|
||||
for _, raw := range c.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &head) == nil && (head.ID == catalogue.BusUsersID() || strings.HasSuffix(head.ID, "."+catalogue.BusUsersID())) {
|
||||
continue
|
||||
}
|
||||
out.Resources = append(out.Resources, raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resourcesByID is each resource canonical, by its id (a resource without one by its place).
|
||||
func resourcesByID(resources []json.RawMessage) map[string][]byte {
|
||||
out := make(map[string][]byte, len(resources))
|
||||
for i, raw := range resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &head)
|
||||
id := head.ID
|
||||
if id == "" {
|
||||
id = "#" + strconv.Itoa(i)
|
||||
}
|
||||
canon, err := canonicalJSON(raw)
|
||||
if err != nil {
|
||||
canon = raw
|
||||
}
|
||||
out[id] = canon
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cut is s cut at MaxBody bytes, saying so.
|
||||
func cut(s string) string {
|
||||
if len(s) <= MaxBody {
|
||||
return s
|
||||
}
|
||||
return s[:MaxBody] + "… (cut at " + strconv.Itoa(MaxBody) + " bytes)"
|
||||
}
|
||||
|
||||
// secretWords are the words of a key whose value may hold a secret or a sealed one: masked in a shown body.
|
||||
var secretWords = []string{"sealed", "secret", "password", "passwd", "token", "private", "key", "credential", "jwt", "salt", "hash"}
|
||||
|
||||
func secretKey(k string) bool {
|
||||
k = strings.ToLower(k)
|
||||
for _, w := range secretWords {
|
||||
if strings.Contains(k, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MaskedPair is two bodies of one resource (either may be nil) as JSON text with every value under a key a secret could
|
||||
// be in (secretWords, at any depth) masked: "<masked: the same on both sides>", "<masked: another seal of the same
|
||||
// value>" or "<masked: another value>" when sameValue says so, "<masked: differs>" otherwise, "<masked>" on one side
|
||||
// alone. A body that is not JSON is shown as its length only.
|
||||
func MaskedPair(composed, running json.RawMessage, sameValue func(a, b string) (same, known bool)) (string, string) {
|
||||
var a, b any
|
||||
okA := composed != nil && json.Unmarshal(composed, &a) == nil
|
||||
okB := running != nil && json.Unmarshal(running, &b) == nil
|
||||
if okA && okB {
|
||||
a, b = maskBoth(a, b, sameValue)
|
||||
} else if okA {
|
||||
a, _ = maskBoth(a, nil, nil)
|
||||
} else if okB {
|
||||
_, b = maskBoth(nil, b, nil)
|
||||
}
|
||||
show := func(v any, raw json.RawMessage, ok bool) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
if !ok {
|
||||
return "<not JSON: " + strconv.Itoa(len(raw)) + " bytes>"
|
||||
}
|
||||
var out bytes.Buffer
|
||||
enc := json.NewEncoder(&out)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(v)
|
||||
return strings.TrimSuffix(out.String(), "\n")
|
||||
}
|
||||
return show(a, composed, okA), show(b, running, okB)
|
||||
}
|
||||
|
||||
func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) {
|
||||
ma, isA := a.(map[string]any)
|
||||
mb, isB := b.(map[string]any)
|
||||
if isA || isB {
|
||||
outA, outB := map[string]any{}, map[string]any{}
|
||||
keys := map[string]bool{}
|
||||
for k := range ma {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range mb {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range keys {
|
||||
va, inA := ma[k]
|
||||
vb, inB := mb[k]
|
||||
if secretKey(k) {
|
||||
word := maskWord(va, vb, inA && inB, sameValue)
|
||||
if inA {
|
||||
outA[k] = word
|
||||
}
|
||||
if inB {
|
||||
outB[k] = word
|
||||
}
|
||||
continue
|
||||
}
|
||||
x, y := maskBoth(va, vb, sameValue)
|
||||
if inA {
|
||||
outA[k] = x
|
||||
}
|
||||
if inB {
|
||||
outB[k] = y
|
||||
}
|
||||
}
|
||||
var ra, rb any
|
||||
if isA {
|
||||
ra = outA
|
||||
} else {
|
||||
ra = a
|
||||
}
|
||||
if isB {
|
||||
rb = outB
|
||||
} else {
|
||||
rb = b
|
||||
}
|
||||
return ra, rb
|
||||
}
|
||||
la, isLA := a.([]any)
|
||||
lb, isLB := b.([]any)
|
||||
if isLA || isLB {
|
||||
outA := make([]any, len(la))
|
||||
outB := make([]any, len(lb))
|
||||
for i := range max(len(la), len(lb)) {
|
||||
var x, y any
|
||||
if i < len(la) {
|
||||
x = la[i]
|
||||
}
|
||||
if i < len(lb) {
|
||||
y = lb[i]
|
||||
}
|
||||
x, y = maskBoth(x, y, sameValue)
|
||||
if i < len(la) {
|
||||
outA[i] = x
|
||||
}
|
||||
if i < len(lb) {
|
||||
outB[i] = y
|
||||
}
|
||||
}
|
||||
var ra, rb any = a, b
|
||||
if isLA {
|
||||
ra = outA
|
||||
}
|
||||
if isLB {
|
||||
rb = outB
|
||||
}
|
||||
return ra, rb
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
|
||||
func maskWord(a, b any, both bool, sameValue func(a, b string) (bool, bool)) string {
|
||||
if !both {
|
||||
return "<masked>"
|
||||
}
|
||||
x, _ := json.Marshal(a)
|
||||
y, _ := json.Marshal(b)
|
||||
if bytes.Equal(x, y) {
|
||||
return "<masked: the same on both sides>"
|
||||
}
|
||||
if sameValue != nil {
|
||||
sa, okA := a.(string)
|
||||
sb, okB := b.(string)
|
||||
if !okA {
|
||||
sa = string(x)
|
||||
}
|
||||
if !okB {
|
||||
sb = string(y)
|
||||
}
|
||||
if same, known := sameValue(sa, sb); known {
|
||||
if same {
|
||||
return "<masked: another seal of the same value>"
|
||||
}
|
||||
return "<masked: another value>"
|
||||
}
|
||||
}
|
||||
return "<masked: differs>"
|
||||
}
|
||||
@@ -3,9 +3,12 @@ package delivery
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func aSigningKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
@@ -100,4 +103,88 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) {
|
||||
if _, err := SignComposedBy(c, pub, func(m []byte) ([]byte, error) { return ed25519.Sign(other, m), nil }); err == nil {
|
||||
t.Errorf("a signature by another key than the one named was accepted")
|
||||
}
|
||||
|
||||
// L3 s1 (ADR 0550 §2): a message the vault or the controller signed under a kind's context verifies against the
|
||||
// signers a node trusts and names which; an unknown key and another kind's context are refused.
|
||||
vaultPub, vault := aSigningKey(t)
|
||||
controllerPub, controller := aSigningKey(t)
|
||||
trusted := []Signer{{Name: SignerController, Key: controllerPub}, {Name: SignerVault, Key: vaultPub}}
|
||||
enrol, _ := SignContext(KindEnrolment)
|
||||
certCtx, _ := SignContext(KindNodeCertificate)
|
||||
msg := []byte("the reply")
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
key ed25519.PrivateKey
|
||||
signed string
|
||||
checked string
|
||||
want string
|
||||
}{
|
||||
{"the vault's key verifies", vault, enrol, enrol, SignerVault},
|
||||
{"the controller's key verifies", controller, enrol, enrol, SignerController},
|
||||
{"an unknown key is refused", other, enrol, enrol, ""},
|
||||
{"the wrong context is refused", vault, certCtx, enrol, ""},
|
||||
} {
|
||||
got, err := VerifyBy(trusted, row.checked, msg, SignMessage(row.key, row.signed, msg))
|
||||
if row.want == "" && err == nil {
|
||||
t.Errorf("%s: verified as %s", row.name, got.Name)
|
||||
}
|
||||
if row.want != "" && (err != nil || got.Name != row.want) {
|
||||
t.Errorf("%s: %+v, %v", row.name, got.Name, err)
|
||||
}
|
||||
}
|
||||
if _, err := SignContext("hand-over"); err == nil {
|
||||
t.Error("a kind the vault does not sign has a context")
|
||||
}
|
||||
// The signers' key reads back only when its fingerprint is its key's, and what a node trusts names both holders.
|
||||
k := NewSignerKey(vaultPub, time.Unix(0, 0))
|
||||
raw, _ := json.Marshal(k)
|
||||
if back, err := DecodeSignerKey(raw); err != nil || back.Fingerprint != SignerFingerprint(vaultPub) {
|
||||
t.Errorf("the signers' key: %+v, %v", back, err)
|
||||
}
|
||||
k.Fingerprint = SignerFingerprint(controllerPub)
|
||||
if raw, _ := json.Marshal(k); true {
|
||||
if _, err := DecodeSignerKey(raw); err == nil {
|
||||
t.Error("a signers' key naming another fingerprint was taken")
|
||||
}
|
||||
}
|
||||
if got := Trusts(trusted, nil); len(got) != 2 || !TrustsKey(got, SignerVault, SignerFingerprint(vaultPub)) ||
|
||||
!TrustsKey(got, SignerController, SignerFingerprint(controllerPub)) {
|
||||
t.Errorf("trusts: %v", got)
|
||||
}
|
||||
// A certificate the vault's authority signs for a node's name and serving key checks against a bundle holding that
|
||||
// authority, and not for another name, another key, or against the controller's alone; a bundle given again adds no
|
||||
// certificate twice.
|
||||
authority := func(pub ed25519.PublicKey, priv ed25519.PrivateKey) string {
|
||||
tpl := AuthorityTemplate(pub, time.Now().Add(-time.Hour))
|
||||
der, err := x509.CreateCertificate(nil, tpl, tpl, pub, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
}
|
||||
vaultCA, controllerCA := authority(vaultPub, vault), authority(controllerPub, controller)
|
||||
parent, _ := ParseCertificate(vaultCA)
|
||||
serving, _ := aSigningKey(t)
|
||||
der, err := x509.CreateCertificate(nil, NodeCertificateTemplate("ace.internal", serving, time.Now().Add(-time.Hour)), parent, serving, vault)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
bundle := AuthorityBundle(controllerCA, AuthorityBundle(controllerCA, vaultCA))
|
||||
if n := strings.Count(bundle, "BEGIN CERTIFICATE"); n != 2 {
|
||||
t.Errorf("the bundle holds %d certificates, want 2", n)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := CheckNodeCertificate(leaf, []string{bundle}, "ace.internal", serving, now); err != nil {
|
||||
t.Errorf("the vault's certificate does not check: %v", err)
|
||||
}
|
||||
for name, err := range map[string]error{
|
||||
"another name": CheckNodeCertificate(leaf, []string{vaultCA}, "g14.internal", serving, now),
|
||||
"another key": CheckNodeCertificate(leaf, []string{vaultCA}, "ace.internal", controllerPub, now),
|
||||
"the controller's alone": CheckNodeCertificate(leaf, []string{controllerCA}, "ace.internal", serving, now),
|
||||
} {
|
||||
if err == nil {
|
||||
t.Errorf("%s: checked", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-5
@@ -402,8 +402,28 @@ type HaveNode struct {
|
||||
// Record is what the machine observes of itself, typed, for every node and composing part to read (issue 1126,
|
||||
// ADR 0555 §3): the node's record lives in its own have key, so it needs no bucket, writer or grant of its own.
|
||||
Record *NodeRecord `json:"record,omitempty"`
|
||||
// Sealed is, on a node composing its own, each value its own composition carries sealed to the node's key, as
|
||||
// applied (Have.Sealed for the node's own, issue 1126): a value the vault seals again after a restart moves no
|
||||
// composition, so a restart applies nothing again.
|
||||
Sealed []string `json:"sealed,omitempty"`
|
||||
// Candidates is the node's answer to each placement candidate naming it, by group (PlacementCandidate): written by
|
||||
// its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a
|
||||
// candidate.
|
||||
Candidates map[string]CandidateAnswer `json:"candidates,omitempty"`
|
||||
// Composes is whose compositions the node applies: its own (ComposesOwn, the catalogue places it) or the
|
||||
// controller's (ComposesController); ComposesWhy says why in words. Written by the node agent at each start, so a
|
||||
// node returning to the controller's compositions when its placement key is deleted says so (issue 1126). Empty
|
||||
// from an engine older than the field.
|
||||
Composes string `json:"composes,omitempty"`
|
||||
ComposesWhy string `json:"composes-why,omitempty"`
|
||||
}
|
||||
|
||||
// Whose compositions a node applies (HaveNode.Composes).
|
||||
const (
|
||||
ComposesOwn = "own"
|
||||
ComposesController = "controller"
|
||||
)
|
||||
|
||||
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
|
||||
// writes it.
|
||||
type Presence struct {
|
||||
@@ -497,17 +517,43 @@ func DecodeHave(data []byte) (Have, error) {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// EncodeHaveNode is n as JSON at Version.
|
||||
func (n HaveNode) check() error {
|
||||
switch n.Composes {
|
||||
case "", ComposesOwn, ComposesController:
|
||||
default:
|
||||
return fmt.Errorf("delivery: a node composes %q, neither %s nor %s", n.Composes, ComposesOwn, ComposesController)
|
||||
}
|
||||
for group, a := range n.Candidates {
|
||||
if group == "" {
|
||||
return fmt.Errorf("delivery: a node's candidate answer names no group")
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return fmt.Errorf("delivery: the answer to %s's candidate: %w", group, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeHaveNode is n as JSON at Version; it refuses a candidate answer CandidateAnswer's rules refuse.
|
||||
func EncodeHaveNode(n HaveNode) ([]byte, error) {
|
||||
if err := n.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.V = Version
|
||||
return json.Marshal(n)
|
||||
}
|
||||
|
||||
// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion).
|
||||
// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion) and a candidate answer that
|
||||
// is not one.
|
||||
func DecodeHaveNode(data []byte) (HaveNode, error) {
|
||||
var n HaveNode
|
||||
err := decode(data, &n)
|
||||
return n, err
|
||||
if err := decode(data, &n); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if err := n.check(); err != nil {
|
||||
return HaveNode{}, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// EncodePresence is p as JSON at Version.
|
||||
@@ -560,7 +606,9 @@ func SameHave(a, b Have) bool {
|
||||
func SameHaveNode(a, b HaveNode) bool {
|
||||
return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
|
||||
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) &&
|
||||
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record))
|
||||
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) &&
|
||||
jsonEqual(a.Sealed, b.Sealed) && jsonEqual(a.Candidates, b.Candidates) && a.Composes == b.Composes &&
|
||||
a.ComposesWhy == b.ComposesWhy
|
||||
}
|
||||
|
||||
// SamePresence says whether two presences are the same value.
|
||||
|
||||
@@ -86,6 +86,14 @@ func TestForgePullCarriesManifestsAndLocks(t *testing.T) {
|
||||
if _, err := EncodeForge(f); err == nil || !strings.Contains(err.Error(), `"c"`) {
|
||||
t.Errorf("locks outside Dirs: %v", err)
|
||||
}
|
||||
// A pull message of the catalogue carries its head's placement files whole (placement candidates, issue 1126).
|
||||
f = Forge{Kind: ForgePull, Repository: CatalogueRepository, Number: 2, Head: "h", Action: PullUpdated,
|
||||
Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true}
|
||||
if data, err := EncodeForge(f); err != nil {
|
||||
t.Errorf("a catalogue pull with placement files: %v", err)
|
||||
} else if got, err := DecodeForge(data); err != nil || !SameForge(got, withV(f)) {
|
||||
t.Errorf("a catalogue pull's placement files changed on the way: %+v, %v", got, err)
|
||||
}
|
||||
for name, f := range map[string]Forge{
|
||||
"refused manifests": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Manifests: map[string]string{"a": "{}"}},
|
||||
"refused locks": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Locks: map[string]map[string]string{"a": {"go.sum": "h"}}},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -87,6 +88,30 @@ func MergeAskKey(group string, heads []Head) (string, error) {
|
||||
return AskKindMerge + "." + part + "." + hash, nil
|
||||
}
|
||||
|
||||
// SettleGroupPrefix begins the group of a settle ask (novox/hq ADR 0556 clause 5): a person's ask to settle deliveries held
|
||||
// because their merge reached the trunk without a passing check. It is kept in the gate's asks under MergeAskKey like a merge
|
||||
// ask, so the teller carries it unchanged; its options bind the act AskVerb names for it. Git forbids a colon in a branch
|
||||
// name, so no pull request's group (its branch) ever begins with it.
|
||||
const SettleGroupPrefix = "settle:"
|
||||
|
||||
// SettleGroup is the group of the settle ask of these delivery ids: the prefix and twelve hex digits of the sha256 over the
|
||||
// ids, sorted and joined by a newline, so the same deliveries asked again keep one key.
|
||||
func SettleGroup(ids []string) string {
|
||||
sorted := append([]string(nil), ids...)
|
||||
sort.Strings(sorted)
|
||||
sum := sha256.Sum256([]byte(strings.Join(sorted, "\n")))
|
||||
return SettleGroupPrefix + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
// AskVerb is the act the options of an ask of the gate bind, by its group: a settle ask's `mesh-delivery.settle`, any other
|
||||
// `mesh-delivery.merge`. The gate composes the act with it and the teller checks a warrant against it.
|
||||
func AskVerb(group string) string {
|
||||
if strings.HasPrefix(group, SettleGroupPrefix) {
|
||||
return "mesh-delivery.settle"
|
||||
}
|
||||
return "mesh-delivery.merge"
|
||||
}
|
||||
|
||||
// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash.
|
||||
func ParseAskKey(key string) (kind, group, hash string, err error) {
|
||||
parts := strings.Split(key, ".")
|
||||
|
||||
@@ -77,6 +77,21 @@ func TestAskID(t *testing.T) { // 1201/AC2
|
||||
if AskID(k1) != AskID(k1) {
|
||||
t.Error("the id is not stable")
|
||||
}
|
||||
// 1280 (ADR 0556 clause 5): a settle ask's group is the same for the same ids in any order, keys like any group, and its
|
||||
// options bind mesh-delivery.settle; every other ask binds mesh-delivery.merge.
|
||||
g := SettleGroup([]string{"novox/b@222222222222", "novox/a@111111111111"})
|
||||
if g != SettleGroup([]string{"novox/a@111111111111", "novox/b@222222222222"}) || !strings.HasPrefix(g, SettleGroupPrefix) || len(g) != len(SettleGroupPrefix)+12 {
|
||||
t.Errorf("SettleGroup = %q", g)
|
||||
}
|
||||
if k, err := MergeAskKey(g, gateHeads()); err != nil || !asks.UsableID(AskID(k)) {
|
||||
t.Errorf("the settle ask's key %q: %v", k, err)
|
||||
}
|
||||
for group, want := range map[string]string{g: "mesh-delivery.settle", "feat/x": "mesh-delivery.merge",
|
||||
"settle/foo": "mesh-delivery.merge", "settle": "mesh-delivery.merge"} { // a branch named settle/... is a merge's group
|
||||
if got := AskVerb(group); got != want {
|
||||
t.Errorf("AskVerb(%q) = %q, want %q", group, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedKey(t *testing.T) { // 1201/AC3
|
||||
|
||||
@@ -90,3 +90,39 @@ func (g ControllerGrant) NamesNode(node, module string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// **The grants part's grants** (novox/hq ADR 0547 §4, §5, ADR 0561 §2, §6; issue 774). The grants part writes the same
|
||||
// keys, with the same value, into its own state `grants` (the bucket PartGrantsBucket), beside the controller's: first in
|
||||
// shadow, which nobody reads, so a tool can compare the two before anything switches. The assigner reads the part's keys
|
||||
// only once the controller's lease is handed away (GrantSource), and the controller's until then.
|
||||
const (
|
||||
// PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it.
|
||||
PartGrantsState = "grants"
|
||||
// PartGrantsBucket is that state's bucket, `<owner module>_<name>`.
|
||||
PartGrantsBucket = "mesh-grants_" + PartGrantsState
|
||||
// PartGrantsRead is that state as a reader names it in `reads`.
|
||||
PartGrantsRead = "mesh-grants." + PartGrantsState
|
||||
// PartGrantsHistory is the revisions the bucket keeps per key.
|
||||
PartGrantsHistory = ControllerGrantsHistory
|
||||
)
|
||||
|
||||
// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: the layout of the
|
||||
// controller's key (ControllerGrantKey), so a key of one is the key of the other and a compare is by key.
|
||||
func PartGrantKey(module, fingerprint string) (string, error) {
|
||||
return ControllerGrantKey(module, fingerprint)
|
||||
}
|
||||
|
||||
// ParsePartGrantKey is the inverse of PartGrantKey.
|
||||
func ParsePartGrantKey(key string) (module, fingerprint string, err error) {
|
||||
return ParseControllerGrantKey(key)
|
||||
}
|
||||
|
||||
// GrantSource names the bucket the assigner reads grants from, and the `reads` name that grants it: the grants part's
|
||||
// once the controller's lease is handed away (buscompose.LeaseHandedAway, which refuses every other value of the lease,
|
||||
// an unreadable one included), the controller's in every other case.
|
||||
func GrantSource(leaseHandedAway bool) (bucket, read string) {
|
||||
if leaseHandedAway {
|
||||
return PartGrantsBucket, PartGrantsRead
|
||||
}
|
||||
return ControllerGrantsBucket, "mesh-controller.grants"
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ type NodeRecord struct {
|
||||
// Authority is the mesh's own certificate as this machine holds it (public): the one its declaration last wrote where
|
||||
// a module asked for it. A node composing its own composes a certificate with it (compose.Input.Authority).
|
||||
Authority string `json:"authority,omitempty"`
|
||||
// Trusts is what this machine trusts a mesh signature from now (Trusts in signers.go, ADR 0550 §2): the controller's
|
||||
// key until the lease is handed away, and the vault's key and authority once it reads them from `signers`. The gate
|
||||
// before the lease write reads every node's from the bus.
|
||||
Trusts []string `json:"trusts,omitempty"`
|
||||
}
|
||||
|
||||
func (r NodeRecord) check() error {
|
||||
|
||||
@@ -18,7 +18,7 @@ var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|privat
|
||||
func TestNodeRecord(t *testing.T) {
|
||||
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
||||
Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}},
|
||||
NetworksSaid: true, Contract: 3}
|
||||
NetworksSaid: true, Contract: 3, Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"}}
|
||||
raw, err := EncodeNodeRecord(hub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Placed is where the indexer's placement (the state `placement`, ADR 0549 §2, §3) places each module, and since when:
|
||||
// the one reader of that state for every part that asks "does module stand on node" (issue 774, design 47's third
|
||||
// milestone), in place of each part reading the controller's assignments (`mesh-controller_assignments`, which goes
|
||||
// with the controller's lease, ADR 0549 §6).
|
||||
//
|
||||
// Its input is the state's revisions in the order the bus wrote them, as a watch with the history (jetstream's
|
||||
// IncludeHistory) hands them: Put takes each. A module stands since the revision of its node's key that first named it
|
||||
// in an unbroken run. A module in the first revision heard of a key stands since the zero time: that revision is the
|
||||
// key's first (the seed of ADR 0549 §4 placed what already ran there) or the oldest the bus still keeps
|
||||
// (PlacementHistory), and either way the module may have run there before it. A key's value that does not read places
|
||||
// nothing on its node, and Put says why.
|
||||
//
|
||||
// Safe for use from a watch and from a reader at once.
|
||||
type Placed struct {
|
||||
mu sync.Mutex
|
||||
since map[string]map[string]time.Time // node -> module -> since when it stands
|
||||
heard map[string]bool // the nodes whose key was heard at all
|
||||
}
|
||||
|
||||
// NewPlaced is a Placed that holds no placement yet.
|
||||
func NewPlaced() *Placed {
|
||||
return &Placed{since: map[string]map[string]time.Time{}, heard: map[string]bool{}}
|
||||
}
|
||||
|
||||
// Put takes one revision of a key of the state `placement` (its value, whether it is a delete, and when the bus wrote
|
||||
// it). It answers the node the key is of and the modules whose standing on it changed (placed or removed), in name
|
||||
// order, and an error for a key or a value that does not read (the node then places nothing).
|
||||
func (p *Placed) Put(key string, value []byte, deleted bool, at time.Time) (node string, changed []string, err error) {
|
||||
node, err = ParsePlacementKey(key)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
now := map[string]bool{}
|
||||
if !deleted {
|
||||
pl, derr := DecodePlacement(value)
|
||||
switch {
|
||||
case derr != nil:
|
||||
err = fmt.Errorf("the placement of %s does not read, so it places nothing there: %w", node, derr)
|
||||
case pl.Node != node:
|
||||
err = fmt.Errorf("the placement under %s names the node %q, so it places nothing there", key, pl.Node)
|
||||
default:
|
||||
for _, m := range pl.Assigned() {
|
||||
now[m] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if !p.heard[node] {
|
||||
p.heard[node] = true
|
||||
at = time.Time{}
|
||||
}
|
||||
was := p.since[node]
|
||||
next := map[string]time.Time{}
|
||||
for m := range now {
|
||||
if s, ok := was[m]; ok {
|
||||
next[m] = s
|
||||
} else {
|
||||
next[m] = at
|
||||
changed = append(changed, m)
|
||||
}
|
||||
}
|
||||
for m := range was {
|
||||
if !now[m] {
|
||||
changed = append(changed, m)
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(p.since, node)
|
||||
} else {
|
||||
p.since[node] = next
|
||||
}
|
||||
slices.Sort(changed)
|
||||
return node, changed, err
|
||||
}
|
||||
|
||||
// Seeded says some node's placement is held: before the first placement file reaches the indexer nothing is placed,
|
||||
// and a reader keeps what it did before (ADR 0549 §5).
|
||||
func (p *Placed) Seeded() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.since) > 0
|
||||
}
|
||||
|
||||
// Since says whether module stands on node, and since when (the zero time: since before the revisions kept).
|
||||
func (p *Placed) Since(node, module string) (time.Time, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
s, ok := p.since[node][module]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// PlacedNodes is every node module is placed on, in name order.
|
||||
func (p *Placed) PlacedNodes(module string) []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
var out []string
|
||||
for node, mods := range p.since {
|
||||
if _, ok := mods[module]; ok {
|
||||
out = append(out, node)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ByNode is, by node, the modules placed on it in name order.
|
||||
func (p *Placed) ByNode() map[string][]string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make(map[string][]string, len(p.since))
|
||||
for node, mods := range p.since {
|
||||
out[node] = slices.Sorted(maps.Keys(mods))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -371,7 +371,8 @@ func (p Placement) Held() []catalogue.Held {
|
||||
// CheckPlacements is the catalogue's own check of every placement file (ADR 0549 §1), over the placements by node, the
|
||||
// modules the catalogue holds and the nodes enrolled. It returns every refusal, in node order, never only the first: a
|
||||
// placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host
|
||||
// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice;
|
||||
// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated (catalogue.Seat's Replicated, ADR 0223), which
|
||||
// is held once per node;
|
||||
// two nodes with one address on the private network; more than one hub.
|
||||
// A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its
|
||||
// ForeseenPorts and Unbound), which the catalogue's composition check reads.
|
||||
@@ -442,6 +443,11 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
|
||||
continue
|
||||
}
|
||||
seat := h.Claim + "/" + h.Kind
|
||||
if s, known := catalogue.SeatNamed(h.Claim); known && s.Replicated {
|
||||
// A replicated mesh seat (ADR 0223) is held on several nodes at once, each answering the same thing;
|
||||
// it is still held once on each node.
|
||||
seat += "/" + node
|
||||
}
|
||||
if other, twice := meshSeats[seat]; twice {
|
||||
refusals = append(refusals, fmt.Sprintf("the seat %s, held once for the mesh, is held on %s and %s", h.Claim, other, node))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user