Compare commits
66
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 | ||
|
|
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"})},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+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"}}},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Placement candidates (issue 1126; ADR 0549 §4: a placement change passes the composition check before any node sees
|
||||
// it). A catalogue pull request that changes a placement file carries every placement file at its head on its pull
|
||||
// message (Forge.Placements). The indexer writes, for each node whose file at the head differs from the trunk's, the
|
||||
// key `<group>.<node>` of its state `placement-candidates`, and deletes it when the pull request merges or closes. It is
|
||||
// a state of its own, since the readers of `placement` refuse any key that is not `placement.<node>`.
|
||||
//
|
||||
// The node it names composes in shadow with the candidate (its own sealed values, the boxes it already runs) and
|
||||
// compares the result with what it runs; it applies nothing. Its answer rides in its own have key, `<node>.node`
|
||||
// (HaveNode.Candidates, by group), which that node alone writes; the assigner's check of the group reads every placed
|
||||
// node's answer at the head and fails on any difference, refusal or silence. Event-driven both ways: the candidate key's
|
||||
// write is the node's event, the `<node>.node` change the check's.
|
||||
const (
|
||||
// PlacementCandidatesState is the indexer's state of placement candidates, and PlacementCandidatesBucket its bucket.
|
||||
PlacementCandidatesState = "placement-candidates"
|
||||
PlacementCandidatesBucket = IndexerModule + "_" + PlacementCandidatesState
|
||||
// PlacementCandidatesHistory is the revisions the bucket keeps per key.
|
||||
PlacementCandidatesHistory = 5
|
||||
)
|
||||
|
||||
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate placement in one group:
|
||||
// `<group>.<node>`, the group escaped as CandidatesKey escapes it, so a node watches `*.<node>` and a group's keys are
|
||||
// `<group>.*`.
|
||||
func PlacementCandidateKey(group, node string) (string, error) {
|
||||
g, err := groupKey(group)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return g + "." + node, nil
|
||||
}
|
||||
|
||||
// ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it.
|
||||
func ParsePlacementCandidateKey(key string) (group, node string, err error) {
|
||||
g, node, ok := strings.Cut(key, ".")
|
||||
if !ok || checkName("node", node) != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <group>.<node>", key)
|
||||
}
|
||||
if group, err = parseGroupKey(g); err != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key: %w", key, err)
|
||||
}
|
||||
return group, node, nil
|
||||
}
|
||||
|
||||
// PlacementCandidate is the value of a candidate key: the group, the catalogue pull request and the head its placement
|
||||
// files were read at, and the node's placement at that head; nil Placement says the head removes the node's file.
|
||||
type PlacementCandidate struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Pull Head `json:"pull"`
|
||||
Placement *Placement `json:"placement,omitempty"`
|
||||
}
|
||||
|
||||
func (c PlacementCandidate) check() error {
|
||||
if c.Group == "" {
|
||||
return fmt.Errorf("delivery: a placement candidate names no group")
|
||||
}
|
||||
if err := checkRepository(c.Pull.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Pull.Number <= 0 || c.Pull.Head == "" {
|
||||
return fmt.Errorf("delivery: a placement candidate needs its pull request's number and head")
|
||||
}
|
||||
if c.Placement != nil {
|
||||
return c.Placement.check()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodePlacementCandidate is c as JSON at Version; it refuses what DecodePlacementCandidate refuses.
|
||||
func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) {
|
||||
if err := c.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.V = Version
|
||||
if c.Placement != nil {
|
||||
p := *c.Placement
|
||||
p.V = Version
|
||||
c.Placement = &p
|
||||
}
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, no group, a pull request without number or
|
||||
// head, and a placement DecodePlacement refuses.
|
||||
func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) {
|
||||
var c PlacementCandidate
|
||||
if err := decode(data, &c); err != nil {
|
||||
return PlacementCandidate{}, err
|
||||
}
|
||||
if err := c.check(); err != nil {
|
||||
return PlacementCandidate{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// The verdicts of a node's answer to a candidate.
|
||||
const (
|
||||
// CandidateSame: every composition the node composes with the candidate is the one it runs, and it runs no other.
|
||||
CandidateSame = "same"
|
||||
// CandidateDiffers: some composition differs (Differs), is added by the candidate (Added) or removed by it (Removed);
|
||||
// whether the change intends it is the assigner's to judge (ADR 0559 §5).
|
||||
CandidateDiffers = "differs"
|
||||
// CandidateRefused: a value is missing for a module the node runs (Refusals), or the node cannot compose with the
|
||||
// candidate at all (Refused says why: an unbound requirement, a manifest it cannot read).
|
||||
CandidateRefused = "refused"
|
||||
)
|
||||
|
||||
// CandidateAnswer is a node's answer to its group's candidate keys, in HaveNode.Candidates (ADR 0559 §4): the head
|
||||
// and the revision of the node's own key in the group it answered (0 when the group has no key of its own for it: its
|
||||
// file is unchanged and it answers as placed at the head or on the trunk, judged by Head alone), its verdict, the modules composed the same, those that differ with both digests, those
|
||||
// the candidate adds (naming the values not yet issued for each, which is no refusal: a module's credential and boxes
|
||||
// are made only once it is placed, ADR 0549 §5) or removes, and each refusal. The node's own composition is named
|
||||
// "node" (HaveNodeWord).
|
||||
type CandidateAnswer struct {
|
||||
Head string `json:"head"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Verdict string `json:"verdict"`
|
||||
Same []string `json:"same,omitempty"`
|
||||
Differs []ModuleDiffers `json:"differs,omitempty"`
|
||||
Added []ModuleAdded `json:"added,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
Refusals []ModuleRefused `json:"refusals,omitempty"`
|
||||
// Refused is why the node could not compose with the candidate at all; Refusals is then empty.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
// Returns is, for a head that deletes the node's placement file, what the node does then: it returns to the
|
||||
// controller's compositions (ReturnsToController), as a node the catalogue does not place composes none of its own
|
||||
// (issue 1126: the seed's revert). Same, Differs, Added and Removed then compare the controller's compositions at
|
||||
// the node's wants with what it runs, and the verdict is theirs; a want the controller holds no composition for is
|
||||
// refused (Refused, without Returns).
|
||||
Returns string `json:"returns,omitempty"`
|
||||
// Note is what the node says of how it answered, in words, beside the verdict: a pass that ran over a minute, inputs
|
||||
// it waited for (issue 1126: the shadow shows its work). It changes no verdict.
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// ReturnsToController is what a node answers a head that deletes its placement file.
|
||||
const ReturnsToController = "the head deletes this node's placement: it returns to the controller's compositions"
|
||||
|
||||
// ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs:
|
||||
// both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty
|
||||
// when every resource is equal, and why in words.
|
||||
type ModuleDiffers struct {
|
||||
Module string `json:"module"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Running string `json:"running,omitempty"`
|
||||
Resource string `json:"resource,omitempty"`
|
||||
Why string `json:"why"`
|
||||
// Bodies is every resource that differs, both sides, each value a secret could be in masked (issue 1126: the shadow
|
||||
// shows its work, so why a module differs is read, not guessed). At most MaxBodies, each side cut at MaxBody bytes.
|
||||
Bodies []ResourceBodies `json:"bodies,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceBodies is one differing resource of a module: its id, why, and its body composed and as it runs, masked
|
||||
// (MaskedPair); a side that does not hold it is empty.
|
||||
type ResourceBodies struct {
|
||||
ID string `json:"id"`
|
||||
Why string `json:"why"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Running string `json:"running,omitempty"`
|
||||
}
|
||||
|
||||
// The bounds of a module's shown bodies.
|
||||
const (
|
||||
MaxBodies = 20
|
||||
MaxBody = 8192
|
||||
)
|
||||
|
||||
// ModuleAdded is one module the candidate places on the node and the node does not run: its composition's digest when
|
||||
// it composed, and the values not yet issued for it (its own secrets, its grants, a host port), each in the words the
|
||||
// composition stood in for it with.
|
||||
type ModuleAdded struct {
|
||||
Module string `json:"module"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Unissued []string `json:"unissued,omitempty"`
|
||||
}
|
||||
|
||||
// ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with.
|
||||
type ModuleRefused struct {
|
||||
Module string `json:"module"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// VerdictOf is the verdict of an answer's lists: refused on any refusal, differs on any difference, addition or
|
||||
// removal, same otherwise.
|
||||
func VerdictOf(a CandidateAnswer) string {
|
||||
switch {
|
||||
case a.Refused != "" || len(a.Refusals) > 0:
|
||||
return CandidateRefused
|
||||
case len(a.Differs)+len(a.Added)+len(a.Removed) > 0:
|
||||
return CandidateDiffers
|
||||
}
|
||||
return CandidateSame
|
||||
}
|
||||
|
||||
func (a CandidateAnswer) check() error {
|
||||
if a.Head == "" {
|
||||
return fmt.Errorf("delivery: a candidate answer names no head")
|
||||
}
|
||||
switch a.Verdict {
|
||||
case CandidateSame, CandidateDiffers, CandidateRefused:
|
||||
default:
|
||||
return fmt.Errorf("delivery: the candidate verdict %q is not same, differs or refused", a.Verdict)
|
||||
}
|
||||
if got := VerdictOf(a); got != a.Verdict {
|
||||
return fmt.Errorf("delivery: a candidate answered %s names what makes it %s", a.Verdict, got)
|
||||
}
|
||||
if a.Refused != "" && len(a.Same)+len(a.Differs)+len(a.Added)+len(a.Removed)+len(a.Refusals) > 0 {
|
||||
return fmt.Errorf("delivery: a candidate the node could not compose names modules")
|
||||
}
|
||||
if a.Returns != "" && (a.Refused != "" || len(a.Refusals) > 0) {
|
||||
return fmt.Errorf("delivery: a candidate the node returns to the controller's compositions on is compared, never refused: " +
|
||||
"a composition the controller holds none of is refused without returning")
|
||||
}
|
||||
for _, d := range a.Differs {
|
||||
if d.Module == "" || d.Why == "" {
|
||||
return fmt.Errorf("delivery: a candidate difference names no module or no why")
|
||||
}
|
||||
}
|
||||
for _, d := range a.Added {
|
||||
if d.Module == "" {
|
||||
return fmt.Errorf("delivery: a module the candidate adds is not named")
|
||||
}
|
||||
}
|
||||
for _, d := range a.Refusals {
|
||||
if d.Module == "" || d.Why == "" {
|
||||
return fmt.Errorf("delivery: a candidate refusal names no module or no why")
|
||||
}
|
||||
}
|
||||
for _, m := range a.Removed {
|
||||
if m == "" {
|
||||
return fmt.Errorf("delivery: a module the candidate removes is not named")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -148,6 +149,16 @@ func TestPlacement(t *testing.T) {
|
||||
if r := CheckPlacements(map[string]Placement{"g14": g14, "novox": novox}, held, enrolled); len(r) != 0 {
|
||||
t.Fatalf("a sound placement is refused: %v", r)
|
||||
}
|
||||
// A replicated mesh seat (ADR 0223) is held on two nodes; a seat that is not replicated is refused there.
|
||||
for seat, refused := range map[string]bool{"mesh-dns-resolver": false, "mesh-broker": true} {
|
||||
a := Placement{Node: "ace", Modules: []PlacedModule{{Module: "dns"}}, Holds: []Holding{{Claim: seat, Scope: "mesh", Module: "dns"}}}
|
||||
b := a
|
||||
b.Node = "novox"
|
||||
r := CheckPlacements(map[string]Placement{"ace": a, "novox": b}, held, enrolled)
|
||||
if (len(r) != 0) != refused {
|
||||
t.Errorf("%s on two nodes: refusals %v, want refused %v", seat, r, refused)
|
||||
}
|
||||
}
|
||||
clash := g14
|
||||
clash.Modules = append([]PlacedModule{{Module: "ghost", Ports: map[string]int{"80": 18080}}}, g14.Modules...)
|
||||
clash.Modules[2] = PlacedModule{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "ace", Module: "postgres"}}}
|
||||
@@ -169,4 +180,165 @@ func TestPlacement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Issue 1126: a catalogue pull request's placement, one candidate key per node, and each node's answer in its have.
|
||||
t.Run("candidates", func(t *testing.T) {
|
||||
key, err := PlacementCandidateKey("feat/1.2", "g14")
|
||||
if err != nil || key != "feat/1=2e2.g14" {
|
||||
t.Fatalf("PlacementCandidateKey = %q, %v", key, err)
|
||||
}
|
||||
if g, n, err := ParsePlacementCandidateKey(key); err != nil || g != "feat/1.2" || n != "g14" {
|
||||
t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", g, n, err)
|
||||
}
|
||||
for _, bad := range []string{"g14", ".g14", "feat.g.14", "feat.", "fe=t.g14"} {
|
||||
if _, _, err := ParsePlacementCandidateKey(bad); err == nil {
|
||||
t.Errorf("ParsePlacementCandidateKey(%q) took it", bad)
|
||||
}
|
||||
}
|
||||
if PlacementCandidatesBucket != "mesh-delivery-indexer_placement-candidates" {
|
||||
t.Errorf("bucket %q", PlacementCandidatesBucket)
|
||||
}
|
||||
pull := Head{Repository: CatalogueRepository, Number: 7, Head: "h1"}
|
||||
for name, c := range map[string]PlacementCandidate{
|
||||
"placed": {Group: "feat/x", Pull: pull, Placement: &g14},
|
||||
"removed": {Group: "feat/x", Pull: pull},
|
||||
} {
|
||||
raw, err := EncodePlacementCandidate(c)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
back, err := DecodePlacementCandidate(raw)
|
||||
if err != nil || back.Group != c.Group || back.Pull != pull || (back.Placement == nil) != (c.Placement == nil) ||
|
||||
(c.Placement != nil && !SamePlacement(*back.Placement, g14)) {
|
||||
t.Errorf("%s: round trip %+v, %v", name, back, err)
|
||||
}
|
||||
}
|
||||
for name, c := range map[string]PlacementCandidate{
|
||||
"no group": {Pull: pull},
|
||||
"no head": {Group: "g", Pull: Head{Repository: CatalogueRepository, Number: 7}},
|
||||
"bad place": {Group: "g", Pull: pull, Placement: &Placement{Node: "a.b"}},
|
||||
"no repo form": {Group: "g", Pull: Head{Repository: "mesh-catalog", Number: 7, Head: "h"}},
|
||||
} {
|
||||
if _, err := EncodePlacementCandidate(c); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
answers := map[string]CandidateAnswer{
|
||||
"same": {Head: "h1", Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}},
|
||||
"differs": {Head: "h1", Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}},
|
||||
"refused": {Head: "h1", Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
|
||||
"added": {Head: "h1", Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}},
|
||||
"a value": {Head: "h1", Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}},
|
||||
// A head that deletes the node's placement file: the node returns to the controller's compositions (issue 1126,
|
||||
// the seed's revert, mesh-catalog #526).
|
||||
"returns": {Head: "h1", Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "node"}},
|
||||
"returns, the controller's differs": {Head: "h1", Verdict: CandidateDiffers, Returns: ReturnsToController, Same: []string{"node"},
|
||||
Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}, Removed: []string{"old"}},
|
||||
}
|
||||
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers,
|
||||
Composes: ComposesOwn, ComposesWhy: "the catalogue places it (placement.n1)"}
|
||||
raw, err := EncodeHaveNode(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeHaveNode(raw)
|
||||
if err != nil || !SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers,
|
||||
Composes: ComposesOwn, ComposesWhy: n.ComposesWhy}) {
|
||||
t.Fatalf("a node's answers changed on the way: %+v, %v", back, err)
|
||||
}
|
||||
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Composes: ComposesOwn, ComposesWhy: n.ComposesWhy}) {
|
||||
t.Error("a node's have without its answers counts as the same")
|
||||
}
|
||||
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers,
|
||||
Composes: ComposesController, ComposesWhy: n.ComposesWhy}) {
|
||||
t.Error("a node's have composing otherwise counts as the same")
|
||||
}
|
||||
if _, err := EncodeHaveNode(HaveNode{Composes: "both"}); err == nil {
|
||||
t.Error("a node composing neither its own nor the controller's was taken")
|
||||
}
|
||||
// CompareComposed: the bus's user list is left out on both sides; anything else that differs is named.
|
||||
file := func(id, content string) json.RawMessage {
|
||||
return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"0600","content":"` + content + `"}`)
|
||||
}
|
||||
fp := "src2:" + strings.Repeat("a", 64)
|
||||
comp := func(rs ...json.RawMessage) Composed {
|
||||
return Composed{Node: "novox", Module: "nats", FP: fp, Resources: rs}
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
composed, running Composed
|
||||
resource, why string
|
||||
}{
|
||||
"user list alone": {comp(file("conf", "a"), file("nats.bus-users", "stand-in")), comp(file("conf", "a"), file("nats.bus-users", "real")), "", ""},
|
||||
"a resource": {comp(file("conf", "a")), comp(file("conf", "b")), "conf", "the resource differs"},
|
||||
"one side": {comp(file("conf", "a"), file("more", "x")), comp(file("conf", "a")), "more", "composed, and not in what runs"},
|
||||
"order": {comp(file("conf", "a"), file("more", "x")), comp(file("more", "x"), file("conf", "a")), "", "the same resources in another order"},
|
||||
} {
|
||||
c := CompareComposed(map[string]Composed{"novox.nats." + fp: tc.composed}, []Composed{tc.running})
|
||||
if tc.why == "" {
|
||||
if len(c.Differs) != 0 || len(c.Same) != 1 {
|
||||
t.Errorf("%s: %+v", name, c)
|
||||
}
|
||||
} else if len(c.Differs) != 1 || c.Differs[0].Resource != tc.resource || c.Differs[0].Why != tc.why ||
|
||||
c.Differs[0].Composed == "" || c.Differs[0].Running == "" || c.Differs[0].Composed == c.Differs[0].Running {
|
||||
t.Errorf("%s: differs %+v, want %s: %s with both digests", name, c.Differs, tc.resource, tc.why)
|
||||
}
|
||||
}
|
||||
// Issue 1126, the shadow shows its work: every differing resource with both bodies, each value a secret could be
|
||||
// in masked (never a secret in an answer), saying whether the two sides hold the same value.
|
||||
sealedFile := func(id, sealed, mode string) json.RawMessage {
|
||||
return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"` + mode + `","sealed":"` + sealed + `"}`)
|
||||
}
|
||||
shown := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"), file("c", "x"))},
|
||||
[]Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"))},
|
||||
func(a, b string) (same, known bool) { return a == "box-2" && b == "box-3", true })
|
||||
if len(shown.Differs) != 1 || len(shown.Differs[0].Bodies) != 3 {
|
||||
t.Fatalf("every differing resource is not shown: %+v", shown.Differs)
|
||||
}
|
||||
for _, row := range []struct{ id, has, hasNot string }{
|
||||
{"a", `"mode":"0600"`, "box-1"},
|
||||
{"b", "another seal of the same value", "box-2"},
|
||||
{"c", `"content":"x"`, ""},
|
||||
} {
|
||||
var body ResourceBodies
|
||||
for _, b := range shown.Differs[0].Bodies {
|
||||
if b.ID == row.id {
|
||||
body = b
|
||||
}
|
||||
}
|
||||
both := body.Composed + body.Running
|
||||
if !strings.Contains(both, row.has) || (row.hasNot != "" && strings.Contains(both, row.hasNot)) {
|
||||
t.Errorf("%s shown as %+v", row.id, body)
|
||||
}
|
||||
}
|
||||
if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`),
|
||||
json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") ||
|
||||
strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "<masked: differs>") ||
|
||||
!strings.Contains(c, "<masked: the same on both sides>") {
|
||||
t.Errorf("masked as %s and %s", c, r)
|
||||
}
|
||||
if c := CompareComposed(nil, []Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" {
|
||||
t.Errorf("the node's own composition run and not composed is removed: %+v", c)
|
||||
}
|
||||
if c := CompareComposed(map[string]Composed{"k": comp(file("conf", "a"))}, nil); len(c.Added) != 1 || c.Added[0].Module != "nats" || c.Added[0].Composed == "" {
|
||||
t.Errorf("a composition not run is added: %+v", c)
|
||||
}
|
||||
for name, a := range map[string]CandidateAnswer{
|
||||
"no head": {Verdict: CandidateSame},
|
||||
"same with a diff": {Head: "h", Verdict: CandidateSame, Differs: answers["differs"].Differs},
|
||||
"differs with none": {Head: "h", Verdict: CandidateDiffers},
|
||||
"refused no reason": {Head: "h", Verdict: CandidateRefused},
|
||||
"unknown verdict": {Head: "h", Verdict: "maybe"},
|
||||
"diff with no module": {Head: "h", Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
|
||||
"same with an added": {Head: "h", Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
|
||||
"differs refusing": {Head: "h", Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
|
||||
"refused and modules": {Head: "h", Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
|
||||
"returns refusing": {Head: "h", Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
|
||||
"returns refused": {Head: "h", Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"},
|
||||
} {
|
||||
if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **The vault signs** (novox/hq ADR 0550 §2; design 47's L3 item d). The vault makes an ed25519 signing key and an x509
|
||||
// authority once, keeps both as its own secrets with their operator copies, and publishes their public halves in its
|
||||
// state `signers`, which only the vault writes and every node principal and composing part reads. Its verb `sign` signs a
|
||||
// node's certificate for that node's own node-engine, from bus state alone (the name from the node's placement, the
|
||||
// serving key from its record), and says `signed` naming the caller, the kind, the node and the digest, never the key.
|
||||
//
|
||||
// A node trusts the vault's key through the bus grants alone (the lead's design limit, 2026-10-28): only the vault may
|
||||
// write `signers`, the same trust the wants and the placement rest on. Until the controller's lease is handed away the
|
||||
// node trusts the controller's key beside it; from then on the vault's alone (LeaseHandedAway). What a node trusts now
|
||||
// it states in its record, NodeRecord.Trusts, so the gate before the lease write reads it from the bus.
|
||||
|
||||
const (
|
||||
// VaultModule is the module holding the seat mesh-vault, whose state `signers` is.
|
||||
VaultModule = "mesh-vault"
|
||||
// SignersState is the vault's state of its public signing halves, and SignersBucket its bucket.
|
||||
SignersState = "signers"
|
||||
SignersBucket = VaultModule + "_" + SignersState
|
||||
// SignersRead is the state as a manifest's `reads` names it.
|
||||
SignersRead = VaultModule + "." + SignersState
|
||||
// SignersKeyKey holds the signing key's public half (SignerKey); SignersAuthorityKey the authority (SignerAuthority).
|
||||
SignersKeyKey = "key"
|
||||
SignersAuthorityKey = "authority"
|
||||
|
||||
// SignVerb is the vault's verb that signs.
|
||||
SignVerb = "sign"
|
||||
// SignedEvent is what the vault says when it signed (Signed), on SignedSubject.
|
||||
SignedEvent = "signed"
|
||||
SignedSubject = "mesh.mod." + VaultModule + ".event." + SignedEvent
|
||||
|
||||
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
|
||||
KindNodeCertificate = "node-certificate"
|
||||
// KindEnrolment is an enrolment reply, signed under its own context; no caller is named until adoption's record
|
||||
// (ADR 0561 §5).
|
||||
KindEnrolment = "enrolment"
|
||||
// KindAuthority is the vault's own authority certificate, which the vault signs for itself.
|
||||
KindAuthority = "authority"
|
||||
|
||||
// SignerController and SignerVault name the two signers a node may trust.
|
||||
SignerController = "controller"
|
||||
SignerVault = "vault"
|
||||
|
||||
// CertificateLife is how long a certificate the vault signs for a node lasts: the controller's ten years
|
||||
// (identity.forever), renewed by asking again, never by a clock running out unseen.
|
||||
CertificateLife = 10 * 365 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// SignKinds are the kinds `sign` knows.
|
||||
var SignKinds = []string{KindNodeCertificate, KindEnrolment, KindAuthority}
|
||||
|
||||
// SignContext is the line a message of kind is signed after, so a signature made for one kind never verifies as
|
||||
// another's (as the composition's and the asks' contexts are kept apart).
|
||||
func SignContext(kind string) (string, error) {
|
||||
if !slices.Contains(SignKinds, kind) {
|
||||
return "", fmt.Errorf("delivery: %q is not a kind the vault signs (%s)", kind, strings.Join(SignKinds, ", "))
|
||||
}
|
||||
return "novox " + kind + " v1\n", nil
|
||||
}
|
||||
|
||||
// SignerKey is the key `key` of `signers`: the vault's signing key's public half in standard base64, its fingerprint,
|
||||
// and when it was made.
|
||||
type SignerKey struct {
|
||||
Key string `json:"key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Made time.Time `json:"made"`
|
||||
}
|
||||
|
||||
// SignerAuthority is the key `authority` of `signers`: the vault's authority certificate as PEM, its fingerprint, and
|
||||
// when it was made.
|
||||
type SignerAuthority struct {
|
||||
Certificate string `json:"certificate"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Made time.Time `json:"made"`
|
||||
}
|
||||
|
||||
// SignerFingerprint is an ed25519 public key's fingerprint, `sha256:<hex>` of its 32 bytes.
|
||||
func SignerFingerprint(key ed25519.PublicKey) string {
|
||||
sum := sha256.Sum256(key)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// AuthorityFingerprint is an authority certificate's fingerprint, `sha256:<hex>` of its DER; refused when certificate is
|
||||
// not one PEM certificate.
|
||||
func AuthorityFingerprint(certificate string) (string, error) {
|
||||
c, err := ParseCertificate(certificate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(c.Raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// ParseCertificate is the first certificate of a PEM text.
|
||||
func ParseCertificate(text string) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode([]byte(strings.TrimSpace(text)))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("delivery: not a PEM certificate")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
// NewSignerKey is the value of `key` for pub.
|
||||
func NewSignerKey(pub ed25519.PublicKey, made time.Time) SignerKey {
|
||||
return SignerKey{Key: base64.StdEncoding.EncodeToString(pub), Fingerprint: SignerFingerprint(pub), Made: made.UTC()}
|
||||
}
|
||||
|
||||
// PublicKey is k's key, checked: an ed25519 public key whose fingerprint is the one k names.
|
||||
func (k SignerKey) PublicKey() (ed25519.PublicKey, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(k.Key)
|
||||
if err != nil || len(raw) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("delivery: the signers' key is not an ed25519 public key in base64")
|
||||
}
|
||||
if fp := SignerFingerprint(raw); fp != k.Fingerprint {
|
||||
return nil, fmt.Errorf("delivery: the signers' key has the fingerprint %s, and names %s", fp, k.Fingerprint)
|
||||
}
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
|
||||
// DecodeSignerKey reads the key `key` of `signers`, refused when it does not check.
|
||||
func DecodeSignerKey(raw []byte) (SignerKey, error) {
|
||||
var k SignerKey
|
||||
if err := json.Unmarshal(raw, &k); err != nil {
|
||||
return SignerKey{}, fmt.Errorf("delivery: %s %s: %w", SignersRead, SignersKeyKey, err)
|
||||
}
|
||||
if _, err := k.PublicKey(); err != nil {
|
||||
return SignerKey{}, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// DecodeSignerAuthority reads the key `authority` of `signers`: one authority certificate (a CA that signs leaves
|
||||
// alone) whose fingerprint is the one named.
|
||||
func DecodeSignerAuthority(raw []byte) (SignerAuthority, error) {
|
||||
var a SignerAuthority
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return SignerAuthority{}, fmt.Errorf("delivery: %s %s: %w", SignersRead, SignersAuthorityKey, err)
|
||||
}
|
||||
c, err := ParseCertificate(a.Certificate)
|
||||
if err != nil {
|
||||
return SignerAuthority{}, err
|
||||
}
|
||||
if !c.IsCA {
|
||||
return SignerAuthority{}, errors.New("delivery: the signers' authority is not an authority certificate")
|
||||
}
|
||||
if fp, _ := AuthorityFingerprint(a.Certificate); fp != a.Fingerprint {
|
||||
return SignerAuthority{}, fmt.Errorf("delivery: the signers' authority has the fingerprint %s, and names %s", fp, a.Fingerprint)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Signer is one key a node trusts, by who holds it (SignerController, SignerVault, or the operator's).
|
||||
type Signer struct {
|
||||
Name string
|
||||
Key ed25519.PublicKey
|
||||
}
|
||||
|
||||
// Fingerprint is the signer's key's fingerprint.
|
||||
func (s Signer) Fingerprint() string { return SignerFingerprint(s.Key) }
|
||||
|
||||
// VerifyBy is the trusted signer whose key verifies sig over context and msg: refused when no trusted key does, saying
|
||||
// so, and when a key is not an ed25519 key. A signature made under another context never verifies.
|
||||
func VerifyBy(trusted []Signer, context string, msg, sig []byte) (Signer, error) {
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return Signer{}, errors.New("delivery: not an ed25519 signature")
|
||||
}
|
||||
message := append([]byte(context), msg...)
|
||||
for _, s := range trusted {
|
||||
if len(s.Key) != ed25519.PublicKeySize {
|
||||
continue
|
||||
}
|
||||
if ed25519.Verify(s.Key, message, sig) {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(trusted))
|
||||
for _, s := range trusted {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
return Signer{}, fmt.Errorf("delivery: the signature verifies against no trusted key (%s)", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
// SignMessage is sig over context and msg by key: what the vault's `sign` does for a kind whose bytes are given.
|
||||
func SignMessage(key ed25519.PrivateKey, context string, msg []byte) []byte {
|
||||
return ed25519.Sign(key, append([]byte(context), msg...))
|
||||
}
|
||||
|
||||
// Signed is the vault's `signed`: who asked, the kind, the node, and the sha256 digest of what was signed. Never a key.
|
||||
type Signed struct {
|
||||
Caller string `json:"caller"`
|
||||
Kind string `json:"kind"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Digest string `json:"digest"`
|
||||
Signer string `json:"signer"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// SignAnswer is what `sign` answers: for a node certificate the certificate as PEM and the authority that signed it,
|
||||
// for an enrolment the signature in base64; always the signer's fingerprint and the digest. Never a key's private half.
|
||||
type SignAnswer struct {
|
||||
Kind string `json:"kind"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Certificate string `json:"certificate,omitempty"`
|
||||
Authority string `json:"authority,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Signer string `json:"signer"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// DigestOf is the digest a `signed` names: `sha256:<hex>` of the bytes signed.
|
||||
func DigestOf(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// NodeCertificateTemplate is the certificate the vault signs for node's internal name, as the controller's authority
|
||||
// shaped it (identity.Certify): the name in the subject and the subject alternative names, server and client use, the
|
||||
// serial derived from the name and the key so the same ask gives the same certificate, and its life from notBefore.
|
||||
func NodeCertificateTemplate(name string, servingKey ed25519.PublicKey, notBefore time.Time) *x509.Certificate {
|
||||
sum := sha256.Sum256(append([]byte(name+"\n"), servingKey...))
|
||||
return &x509.Certificate{
|
||||
SerialNumber: new(big.Int).SetBytes(sum[:16]),
|
||||
Subject: pkix.Name{CommonName: name},
|
||||
DNSNames: []string{name},
|
||||
NotBefore: notBefore.UTC().Truncate(time.Second),
|
||||
NotAfter: notBefore.UTC().Truncate(time.Second).Add(CertificateLife),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorityTemplate is the vault's authority certificate for its key, made once: an authority that signs leaves and no
|
||||
// other authority, as the controller's.
|
||||
func AuthorityTemplate(key ed25519.PublicKey, made time.Time) *x509.Certificate {
|
||||
sum := sha256.Sum256(append([]byte("authority\n"), key...))
|
||||
return &x509.Certificate{
|
||||
SerialNumber: new(big.Int).SetBytes(sum[:16]),
|
||||
Subject: pkix.Name{CommonName: "the mesh's vault"},
|
||||
NotBefore: made.UTC().Truncate(time.Second),
|
||||
NotAfter: made.UTC().Truncate(time.Second).Add(2 * CertificateLife),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
MaxPathLen: 0,
|
||||
MaxPathLenZero: true,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckNodeCertificate says why certificate is not a certificate of name for servingKey by one of the authorities, or
|
||||
// nil: a node uses it to tell whether its record holds a certificate from an authority it trusts, and another node to
|
||||
// check one the vault signed (ADR 0550 §2's live check).
|
||||
func CheckNodeCertificate(certificate string, authorities []string, name string, servingKey ed25519.PublicKey, at time.Time) error {
|
||||
c, err := ParseCertificate(certificate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
for _, a := range authorities {
|
||||
pool.AppendCertsFromPEM([]byte(a)) // a bundle is every certificate it holds
|
||||
}
|
||||
if _, err := c.Verify(x509.VerifyOptions{Roots: pool, DNSName: name, CurrentTime: at,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil {
|
||||
return fmt.Errorf("delivery: the certificate of %s is not one a trusted authority signed for it: %w", name, err)
|
||||
}
|
||||
held, ok := c.PublicKey.(ed25519.PublicKey)
|
||||
if !ok || !held.Equal(servingKey) {
|
||||
return fmt.Errorf("delivery: the certificate of %s binds another key than the node serves with", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorityBundle is the authorities given as one PEM text, each certificate once, in the order given, blanks left out:
|
||||
// what a module asking for the mesh's authority is given once the vault signs beside the controller (ADR 0550 §2). An
|
||||
// authority given is split into its certificates, so a bundle given again (the one a node kept from its last
|
||||
// composition) adds nothing twice; a text that holds no PEM block is taken whole.
|
||||
func AuthorityBundle(authorities ...string) string {
|
||||
var out []string
|
||||
for _, a := range authorities {
|
||||
for _, piece := range pemPieces(a) {
|
||||
if !slices.Contains(out, piece) {
|
||||
out = append(out, piece)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(out, "\n") + "\n"
|
||||
}
|
||||
|
||||
// pemPieces is each PEM block of text re-encoded, or the trimmed text whole when it holds none.
|
||||
func pemPieces(text string) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
rest := []byte(text)
|
||||
for {
|
||||
block, more := pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
out = append(out, strings.TrimSpace(string(pem.EncodeToMemory(block))))
|
||||
rest = more
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{text}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Trusts is what a node states it trusts now (NodeRecord.Trusts): `<who> key <fingerprint>` for each signer and
|
||||
// `<who> authority <fingerprint>` for each authority certificate, by who holds it, each once, sorted. The gate before the
|
||||
// lease write reads `vault key <fingerprint>` in every node's record.
|
||||
func Trusts(signers []Signer, authorities map[string]string) []string {
|
||||
var out []string
|
||||
for _, s := range signers {
|
||||
if len(s.Key) == ed25519.PublicKeySize {
|
||||
out = append(out, s.Name+" key "+s.Fingerprint())
|
||||
}
|
||||
}
|
||||
for who, a := range authorities {
|
||||
if fp, err := AuthorityFingerprint(a); err == nil {
|
||||
out = append(out, who+" authority "+fp)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
// TrustsKey says whether trusts names the key with fingerprint fp held by who.
|
||||
func TrustsKey(trusts []string, who, fp string) bool { return slices.Contains(trusts, who+" key "+fp) }
|
||||
@@ -540,6 +540,8 @@ type Forge struct {
|
||||
// Placements are, on a merged message of the catalogue repository, every placement file of the merge commit, the
|
||||
// bytes exact, by node (PlacementFile; novox/hq ADR 0549 §2), and PlacementsRead says they were read, so a catalogue
|
||||
// whose last placement file was removed is told from a message that read none (older writers, another repository).
|
||||
// On a pull message of the catalogue (CatalogueRepository) they are every placement file at the pull request's head,
|
||||
// read whole or not at all, which the indexer writes as placement candidates (PlacementCandidate).
|
||||
Placements map[string]string `json:"placements,omitempty"`
|
||||
PlacementsRead bool `json:"placements-read,omitempty"`
|
||||
}
|
||||
@@ -554,8 +556,10 @@ func (f Forge) check() error {
|
||||
if len(f.Open) > 0 {
|
||||
return fmt.Errorf("delivery: only an open message names open pull requests")
|
||||
}
|
||||
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged {
|
||||
return fmt.Errorf("delivery: only a merged message carries placement files")
|
||||
// A pull message of the catalogue carries its head's placement files, so its change of placement is composed and
|
||||
// answered by the nodes it places before it merges (placement candidates, issue 1126; ADR 0549 §4).
|
||||
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged && (f.Kind != ForgePull || f.Repository != CatalogueRepository) {
|
||||
return fmt.Errorf("delivery: only a merged message, or a pull message of the catalogue, carries placement files")
|
||||
}
|
||||
if len(f.Placements) > 0 && !f.PlacementsRead {
|
||||
return fmt.Errorf("delivery: a message with placement files must say it read them (placements-read)")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user