Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c9ddd32b | ||
|
|
94cee5d199 | ||
|
|
52056010fe | ||
|
|
307b6234e6 | ||
|
|
dbcaa29a1a | ||
|
|
394fec8a63 | ||
|
|
c2999a4e13 | ||
|
|
d4c61ee8ff | ||
|
|
76f7c4d223 | ||
|
|
f609be7864 | ||
|
|
d747c1128e | ||
|
|
f9afe32888 | ||
|
|
77b48a4f1a | ||
|
|
e84a121b8a | ||
|
|
06b13ec1cc | ||
|
|
0830fcb25e | ||
|
|
b03bf4c118 | ||
|
|
53f2649369 | ||
|
|
8eafc0c5ce | ||
|
|
50de0a5a71 | ||
|
|
2323ba19db | ||
|
|
63607ba169 | ||
|
|
82c54870ed | ||
|
|
79231a9e6c | ||
|
|
222d605f6f | ||
|
|
3a8997c58a | ||
|
|
d6db3f38e6 | ||
|
|
18a47ac3c7 | ||
|
|
1386219d4d | ||
|
|
10d4d2f845 | ||
|
|
b3100c55a1 | ||
|
|
0062bdc4a6 | ||
|
|
a1a688b19e | ||
|
|
54b17164ef | ||
|
|
3ed9a0e342 | ||
|
|
9b5657a3d6 | ||
|
|
1d4637e46b | ||
|
|
fefa8b88ea | ||
|
|
8d9b3f779d | ||
|
|
da506bb3b8 | ||
|
|
af08be36a2 | ||
|
|
6b43afa2ab | ||
|
|
f91340fe82 | ||
|
|
6aeb401095 | ||
|
|
5228a5782d | ||
|
|
9e5666b4d6 | ||
|
|
c006aa8242 | ||
|
|
d7c790d242 | ||
|
|
38dbe1d3a3 | ||
|
|
a2dbcf670b | ||
|
|
e987a8280b | ||
|
|
45f5f76811 | ||
|
|
4b4a173d84 | ||
|
|
0282229fd9 | ||
|
|
5a2a7eebfd | ||
|
|
004bbc2ce2 | ||
|
|
fed64292f0 | ||
|
|
7a82384e21 | ||
|
|
7c565f2aa5 | ||
|
|
edc6f269f3 | ||
|
|
902e3c9e96 | ||
|
|
c16e6213e1 | ||
|
|
98f2f24069 | ||
|
|
0de3006415 | ||
|
|
7aedd28f01 | ||
|
|
f387581b6b | ||
|
|
eda82b1598 | ||
|
|
06742d0448 | ||
|
|
3d021445f7 | ||
|
|
1618837ea9 | ||
|
|
6097e4f3e0 | ||
|
|
8520a4c099 | ||
|
|
70de9429e5 | ||
|
|
79eb58efe4 | ||
|
|
3028c7ec4e | ||
|
|
1062b33d32 | ||
|
|
16b8d8a1ee | ||
|
|
be77f66563 | ||
|
|
362a3f1378 | ||
|
|
03da552cb3 | ||
|
|
4e0c57771e | ||
|
|
b31c4580aa | ||
|
|
47eb100992 | ||
|
|
7359d187db | ||
|
|
47b1dcf08f | ||
|
|
e32d777530 | ||
|
|
472d5f3144 | ||
|
|
2fd5ffdd62 | ||
|
|
d48365aed0 | ||
|
|
7a8e9f9706 | ||
|
|
d7a758894a | ||
|
|
04de3e5da2 | ||
|
|
9f92233a28 | ||
|
|
8ad9372b19 | ||
|
|
3236cbad43 | ||
|
|
a262c7dbe0 | ||
|
|
05b6783103 | ||
|
|
56de5ba840 | ||
|
|
43eef46c41 | ||
|
|
cadd7b1b2f | ||
|
|
062c29c3a2 | ||
|
|
a37d2622ed | ||
|
|
ba398e2460 | ||
|
|
82f10c3bc9 | ||
|
|
7e99c354ac | ||
|
|
8e81d87610 | ||
|
|
db1007ffdd | ||
|
|
981a35b598 | ||
|
|
b0cbc4e6ac | ||
|
|
a2759ec92e | ||
|
|
f178323b57 | ||
|
|
684acb922d | ||
|
|
e78e881ecb | ||
|
|
68d65e6bbf | ||
|
|
823d52051f | ||
|
|
b4310dfb8e | ||
|
|
e44cbaf57f | ||
|
|
1f70c4c0aa | ||
|
|
5ecda46124 | ||
|
|
aa8e9afc7c | ||
|
|
54a8afac73 | ||
|
|
43de3c937d | ||
|
|
47a66e3c0d | ||
|
|
81ec7bd7de | ||
|
|
e40dd0f56a |
@@ -571,3 +571,8 @@ type ProofAnswer struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259
|
||||
// §2 to admit a node's engine as an asker): `node-engine-<node>`, the last token of the by-caller subjects granted to that
|
||||
// node's engine account alone, so no engine acts on another node's warrant.
|
||||
func EngineAsker(node string) string { return "node-engine-" + node }
|
||||
|
||||
@@ -200,6 +200,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if runner != "" {
|
||||
args["runner"] = runner
|
||||
}
|
||||
if len(job.NodeNames) > 0 {
|
||||
args[delivery.NodeNamesArg] = job.NodeNames // the names check's (ADR 0567 §12)
|
||||
}
|
||||
return args
|
||||
}
|
||||
test := func(args map[string]any) (bool, error) {
|
||||
|
||||
@@ -143,6 +143,15 @@ test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
|
||||
t.Fatalf("the unit level %+v", u)
|
||||
}
|
||||
|
||||
// ADR 0567 §12: a check's node names reach the digest and test tools, for the names check (a kept verdict misses when
|
||||
// they change).
|
||||
r.calls = nil
|
||||
if _, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, NodeNames: []string{"anchor"},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil || !strings.Contains(strings.Join(r.calls, "\n"), `digest {"level":"unit","node-names":["anchor"]`) ||
|
||||
!strings.Contains(strings.Join(r.calls, "\n"), `test {"level":"unit","node-names":["anchor"]`) {
|
||||
t.Fatalf("the node names are not handed to the digest and test tools: %v, %v", r.calls, err)
|
||||
}
|
||||
|
||||
// Issue 890/AC2: a package declaring node-build-node is not tested on a node that lacks it but named unmet, and is
|
||||
// tested on a node that has it.
|
||||
for _, has := range []bool{false, true} {
|
||||
|
||||
+31
-37
@@ -8,14 +8,15 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: "we should only use g14 as a builder
|
||||
// outside office hours", "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting names, per
|
||||
// node, the window in the node's local time in which it takes asks; outside it the capacity judged by is 0: steps running
|
||||
// finish, no new one is taken. A node it does not name, or an empty setting, builds at any hour. The puller judges it at
|
||||
// every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of another part).
|
||||
// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: the laptop builds only outside
|
||||
// office hours, "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting is the node's window
|
||||
// in its local time, a config value set on each node's layer of mesh-config (ADR 0567 §12: no repository names a node);
|
||||
// outside it the capacity judged by is 0: steps running finish, no new one is taken. An empty value builds at any hour.
|
||||
// The puller judges it at every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of
|
||||
// another part).
|
||||
|
||||
// BuildHoursSetting is the build modules' setting of the nodes' build windows: `<node>=HH:MM-HH:MM` pairs separated by
|
||||
// commas, in the node's local time; an end before its start crosses midnight (g14=18:00-06:00).
|
||||
// BuildHoursSetting is the build modules' setting of the node's build window: HH:MM-HH:MM in the node's local time; an
|
||||
// end before its start crosses midnight (18:00-06:00).
|
||||
const BuildHoursSetting = "build-hours"
|
||||
|
||||
// BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive.
|
||||
@@ -51,35 +52,28 @@ func (w BuildWindow) NextEdge(t time.Time) time.Time {
|
||||
return next
|
||||
}
|
||||
|
||||
// ParseBuildHours is node's window in a build-hours setting: nil when the setting is empty or does not name the node.
|
||||
func ParseBuildHours(setting, node string) (*BuildWindow, error) {
|
||||
var found *BuildWindow
|
||||
for _, pair := range strings.Split(setting, ",") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
name, span, ok := strings.Cut(pair, "=")
|
||||
from, to, ok2 := strings.Cut(span, "-")
|
||||
if !ok || !ok2 || strings.TrimSpace(name) == "" {
|
||||
return nil, fmt.Errorf("the build hours %q: %q is not <node>=HH:MM-HH:MM", setting, pair)
|
||||
}
|
||||
start, err := minuteOf(from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
|
||||
}
|
||||
end, err := minuteOf(to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
|
||||
}
|
||||
if start == end {
|
||||
return nil, fmt.Errorf("the build hours %q: %s opens and closes at the same minute", setting, pair)
|
||||
}
|
||||
if strings.TrimSpace(name) == node {
|
||||
found = &BuildWindow{Start: start, End: end}
|
||||
}
|
||||
// ParseBuildWindow reads a build-hours value, HH:MM-HH:MM: nil when it is empty.
|
||||
func ParseBuildWindow(setting string) (*BuildWindow, error) {
|
||||
setting = strings.TrimSpace(setting)
|
||||
if setting == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return found, nil
|
||||
from, to, ok := strings.Cut(setting, "-")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the build hours %q are not HH:MM-HH:MM", setting)
|
||||
}
|
||||
start, err := minuteOf(from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
|
||||
}
|
||||
end, err := minuteOf(to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
|
||||
}
|
||||
if start == end {
|
||||
return nil, fmt.Errorf("the build hours %q open and close at the same minute", setting)
|
||||
}
|
||||
return &BuildWindow{Start: start, End: end}, nil
|
||||
}
|
||||
|
||||
// minuteOf reads HH:MM as minutes after midnight.
|
||||
@@ -92,7 +86,7 @@ func minuteOf(s string) (int, error) {
|
||||
return h*60 + m, nil
|
||||
}
|
||||
|
||||
// NodeBuildHours is node's build window, read from the module's settings file at path at every call, and why it is
|
||||
// NodeBuildHours is the node's build window, read from the module's settings file at path at every call, and why it is
|
||||
// none when the value cannot be read. No path, no file value or an empty one is no window and no fault. A value that
|
||||
// cannot be read is no window too, so the node builds at any hour; the setting is a preference of hours, not a safety
|
||||
// bound (a node that must never build has capacity 0). It is loud: the fault is said once per distinct fault through
|
||||
@@ -125,7 +119,7 @@ func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*Build
|
||||
if v, ok := s[BuildHoursSetting]; ok && json.Unmarshal(v, &setting) != nil {
|
||||
return fault(fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, v))
|
||||
}
|
||||
w, err := ParseBuildHours(setting, node)
|
||||
w, err := ParseBuildWindow(setting)
|
||||
if err != nil {
|
||||
return fault(err.Error())
|
||||
}
|
||||
|
||||
+35
-35
@@ -4,45 +4,25 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
|
||||
)
|
||||
|
||||
// CapacitySetting is the build modules' setting of each node's build capacity (novox/hq ADR 0525 §5): `<node>=<asks at
|
||||
// once>` pairs separated by commas, as the builder's was (ADR 0485 §1). Each build module declares it with
|
||||
// DefaultCapacities as its default, and the controller lays it into the module's settings file.
|
||||
// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how
|
||||
// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names
|
||||
// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config)
|
||||
// lays the node's value into the module's settings file.
|
||||
const CapacitySetting = "capacity"
|
||||
|
||||
// DefaultCapacities is the setting's default, the builder's before R4g: the control-node (novox) and the laptop (g14)
|
||||
// build nothing.
|
||||
const DefaultCapacities = "shanks=3,ace=2,g14=0,novox=0"
|
||||
|
||||
// NodeCapacity is node's build capacity, read from the module's settings file at path at every call, so a change takes
|
||||
// effect at the next pull; with no path it reads fallback. A node the setting does not name runs one ask at a time (as
|
||||
// the builder's did). A file or a setting that cannot be read gives 0 and says why: pulling on a setting nobody can read
|
||||
// could build on a node that must not.
|
||||
func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func() int {
|
||||
// NodeCapacity is the node's build capacity, read from the module's settings file at path at every call, so a change
|
||||
// takes effect at the next pull. No path, no file value, or a value that is not a whole number of at least 0 gives 0 and
|
||||
// a value that cannot be read says why: pulling on a setting nobody can read could build on a node that must not.
|
||||
func NodeCapacity(path, node string, logf func(string, ...any)) func() int {
|
||||
say := func(format string, args ...any) {
|
||||
if logf != nil {
|
||||
logf(format, args...)
|
||||
}
|
||||
}
|
||||
return func() int {
|
||||
setting := fallback
|
||||
if path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
|
||||
return 0
|
||||
}
|
||||
var s map[string]any
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
|
||||
return 0
|
||||
}
|
||||
setting, _ = s[CapacitySetting].(string)
|
||||
}
|
||||
n, err := capacityOf(setting, node)
|
||||
n, err := ReadCapacity(path)
|
||||
if err != nil {
|
||||
say("%v, so %s pulls nothing", err, node)
|
||||
return 0
|
||||
@@ -51,11 +31,31 @@ func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func()
|
||||
}
|
||||
}
|
||||
|
||||
// capacityOf is node's capacity in a setting's `<node>=<asks>` pairs: DefaultCapacity for a node it does not name.
|
||||
func capacityOf(setting, node string) (int, error) {
|
||||
caps, err := buildrun.ParseCapacity(setting)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the build capacity: %w", err)
|
||||
// ReadCapacity is the capacity the settings file at path holds, and why it cannot be read when it cannot (then 0).
|
||||
func ReadCapacity(path string) (int, error) {
|
||||
if path == "" {
|
||||
return 0, nil
|
||||
}
|
||||
return (&buildrun.Places{Capacity: caps}).CapacityOf(node), nil
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the build capacity cannot be read: %w", err)
|
||||
}
|
||||
var s map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return 0, fmt.Errorf("the settings file %s is not JSON: %w", path, err)
|
||||
}
|
||||
return capacityOf(s[CapacitySetting])
|
||||
}
|
||||
|
||||
// capacityOf is the capacity a config value holds: absent or null is 0; anything but a whole number of at least 0 is
|
||||
// refused, naming it.
|
||||
func capacityOf(value json.RawMessage) (int, error) {
|
||||
if len(value) == 0 || string(value) == "null" {
|
||||
return 0, nil
|
||||
}
|
||||
var n int
|
||||
if err := json.Unmarshal(value, &n); err != nil || n < 0 {
|
||||
return 0, fmt.Errorf("the build capacity %s is not a whole number of asks at once, 0 or more", value)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
// **A build seat's capacity follows the CPU its node gives builds** (issue 1806's follow-up; issue 1890, 2026-10-26
|
||||
// 19:41: under the 50 % cap shanks' mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and
|
||||
// 19:41: under the 50 % cap the workstation's mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and
|
||||
// each step ran past the 30-minute step bound with no test run). The configured capacity is an upper bound; the node's
|
||||
// build slice bounds it again by the cores a step needs: floor(the slice's quota in cores / cores-per-step), one at
|
||||
// least. The quota is read live from the cgroup at every judgement, so a changed cap is followed at the next take
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
const CoresPerStepSetting = "cores-per-step"
|
||||
|
||||
// DefaultCoresPerStep are the build modules' defaults by language, from measured steps (2026-10-26): a full mesh-catalog
|
||||
// check on mesh-build-go takes what it is given (three at once took 965 % of shanks' 1200 % slice, about 4 cores each,
|
||||
// check on mesh-build-go takes what it is given (three at once took 965 % of the workstation's 1200 % slice, about 4 cores each,
|
||||
// and ran past the 30-minute step bound), so a Go step is given 6, half again what starved; a Node step (tsc, esbuild, a
|
||||
// test runner: median 15 s) and an image build (buildah, its RUN lines mostly fetches) need 2.
|
||||
var DefaultCoresPerStep = map[string]float64{delivery.AskGo: 6, delivery.AskNode: 2, delivery.AskImage: 2}
|
||||
|
||||
@@ -10,39 +10,26 @@ 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) {
|
||||
if got := NodeCapacity("", DefaultCapacities, "novox", nil)(); got != 0 {
|
||||
t.Errorf("novox = %d by default, want 0", got)
|
||||
}
|
||||
if got := NodeCapacity("", DefaultCapacities, "shanks", nil)(); got != 3 {
|
||||
t.Errorf("shanks = %d by default, want 3", got)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "settings.json")
|
||||
os.WriteFile(file, []byte(`{"capacity":"shanks=1,ace=2"}`), 0o600)
|
||||
capacity := NodeCapacity(file, DefaultCapacities, "shanks", nil)
|
||||
if got := capacity(); got != 1 {
|
||||
t.Errorf("shanks = %d from the file, want 1", got)
|
||||
}
|
||||
os.WriteFile(file, []byte(`{"capacity":"shanks=4"}`), 0o600)
|
||||
if got := capacity(); got != 4 {
|
||||
t.Errorf("shanks = %d after the change, want 4", got)
|
||||
}
|
||||
if got := NodeCapacity(file, "", "pi", nil)(); got != 1 {
|
||||
t.Errorf("a node not named = %d, want 1", got)
|
||||
}
|
||||
os.WriteFile(file, []byte(`{"capacity":"shanks=lots"}`), 0o600)
|
||||
if got := capacity(); got != 0 {
|
||||
t.Errorf("an unreadable setting = %d, want 0", got)
|
||||
}
|
||||
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) {
|
||||
func TestNodeCapacityAndLimits(t *testing.T) {
|
||||
// ADR 0567 §12: the capacity is the node's integer, its default 0, and no default names a node.
|
||||
if got := NodeCapacity("", "laptop", nil)(); got != 0 {
|
||||
t.Errorf("no settings file = %d, want 0", got)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "settings.json")
|
||||
capacity := NodeCapacity(file, "anchor", nil)
|
||||
for value, want := range map[string]int{`{"capacity":1}`: 1, `{"capacity":4}`: 4, `{}`: 0, `{"capacity":null}`: 0,
|
||||
`{"capacity":"anchor=3"}`: 0, `{"capacity":-1}`: 0, `{"capacity":1.5}`: 0} {
|
||||
os.WriteFile(file, []byte(value), 0o600)
|
||||
if got := capacity(); got != want {
|
||||
t.Errorf("%s = %d, want %d", value, got, want)
|
||||
}
|
||||
}
|
||||
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), "anchor", nil)(); got != 0 {
|
||||
t.Errorf("a missing file = %d, want 0", got)
|
||||
}
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "")
|
||||
if l := NodeLimits(3); l.Threads != 0 || l.Slots != 3 {
|
||||
t.Errorf("without the word: %+v", l)
|
||||
@@ -71,13 +58,13 @@ 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 {
|
||||
file string
|
||||
want float64
|
||||
}{{`{"capacity":"shanks=2"}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} {
|
||||
}{{`{"capacity":2}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} {
|
||||
os.WriteFile(file, []byte(c.file), 0o600)
|
||||
if got := cores(); got != c.want {
|
||||
t.Errorf("cores-per-step from %s = %g, want %g", c.file, got, c.want)
|
||||
@@ -89,6 +76,8 @@ func TestNodeLimitsAndTheCPUBound(t *testing.T) {
|
||||
|
||||
// The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00, its local time. Outside its build hours the
|
||||
// capacity judged by is 0, said with the window and when it opens; a node not named, or a bad value, builds at any hour.
|
||||
// And a node its module proves cannot build (ace without /dev/net/tun, 2026-10-28) judges by 0 at every hour, saying
|
||||
// why, so its asks go to a node that can.
|
||||
cet := time.FixedZone("CET", 3600)
|
||||
day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) }
|
||||
was := now
|
||||
@@ -99,17 +88,21 @@ func TestNodeLimitsAndTheCPUBound(t *testing.T) {
|
||||
effective int
|
||||
edge string // the window's next edge, HH:MM; "" for no window
|
||||
bad bool
|
||||
ineligible string // what the module's Eligible says; "" for eligible
|
||||
}{
|
||||
{"g14 in the office", "g14=18:00-06:00", "g14", day(10, 0), 0, "18:00", false},
|
||||
{"g14 in the evening", "g14=18:00-06:00", "g14", day(18, 0), 2, "06:00", false},
|
||||
{"g14 past midnight", "g14=18:00-06:00", "g14", day(5, 59), 2, "06:00", false},
|
||||
{"g14 at the close", "g14=18:00-06:00", "g14", day(6, 0), 0, "18:00", false},
|
||||
{"a day window", "shanks=09:00-17:30,g14=18:00-06:00", "shanks", day(17, 29), 2, "17:30", false},
|
||||
{"a node not named", "g14=18:00-06:00", "ace", day(10, 0), 2, "", false},
|
||||
{"a bad time", "g14=18:00-6", "g14", day(10, 0), 2, "", true},
|
||||
{"no span", "g14=18:00-18:00", "g14", day(10, 0), 2, "", true},
|
||||
{"the laptop in the office", "18:00-06:00", "laptop", day(10, 0), 0, "18:00", false, ""},
|
||||
{"the laptop in the evening", "18:00-06:00", "laptop", day(18, 0), 2, "06:00", false, ""},
|
||||
{"the laptop past midnight", "18:00-06:00", "laptop", day(5, 59), 2, "06:00", false, ""},
|
||||
{"the laptop at the close", "18:00-06:00", "laptop", day(6, 0), 0, "18:00", false, ""},
|
||||
{"a day window", "09:00-17:30", "anchor", day(17, 29), 2, "17:30", false, ""},
|
||||
{"no window", "", "anchor", day(10, 0), 2, "", false, ""},
|
||||
{"a bad time", "18:00-6", "laptop", day(10, 0), 2, "", true, ""},
|
||||
{"no span", "18:00-18:00", "laptop", day(10, 0), 2, "", true, ""},
|
||||
{"the old form naming a node", "laptop=18:00-06:00", "laptop", day(10, 0), 2, "", true, ""},
|
||||
{"a node without /dev/net/tun", "", "anchor", day(10, 0), 0, "", false, "/dev/net/tun cannot be opened: no such device"},
|
||||
{"not eligible within its hours", "18:00-06:00", "laptop", day(19, 0), 0, "06:00", false, "/dev/net/tun cannot be opened: no such device"},
|
||||
} {
|
||||
w, err := ParseBuildHours(c.setting, c.node)
|
||||
w, err := ParseBuildWindow(c.setting)
|
||||
if (err != nil) != c.bad {
|
||||
t.Errorf("%s: parse %v", c.name, err)
|
||||
}
|
||||
@@ -124,11 +117,16 @@ func TestNodeLimitsAndTheCPUBound(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "settings.json")
|
||||
os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600)
|
||||
now = func() time.Time { return c.at }
|
||||
p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil)}
|
||||
p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil),
|
||||
Eligible: func() string { return c.ineligible }}
|
||||
got := p.CapacityDetail()
|
||||
if got.Effective != c.effective || got.OutsideHours != (c.effective == 0) || (c.effective == 0 && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) {
|
||||
outside := c.effective == 0 && c.ineligible == ""
|
||||
if got.Effective != c.effective || got.OutsideHours != outside || (outside && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) {
|
||||
t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective)
|
||||
}
|
||||
if got.Ineligible != c.ineligible || (c.ineligible != "" && !strings.HasPrefix(got.Why, "0, not eligible: "+c.ineligible+" (otherwise ")) {
|
||||
t.Errorf("%s: not eligible is not said: %+v", c.name, got)
|
||||
}
|
||||
// A value that cannot be read builds at any hour, loudly: the record names the value and says why.
|
||||
if c.bad && (!strings.Contains(got.BuildHoursFault, c.setting) || !strings.Contains(got.Why, "at any hour, because its build hours cannot be read")) {
|
||||
t.Errorf("%s: a bad value is not said in the record: %+v", c.name, got)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// **A running ask says its phases** (issue 1890, 2026-10-26 19:09: a mesh-catalog try's step on shanks ran 30 minutes
|
||||
// **A running ask says its phases** (issue 1890, 2026-10-26 19:09: a mesh-catalog try's step on the workstation ran 30 minutes
|
||||
// and its module's journal held no line naming its ask, so nobody could tell whether it fetched, linted or tested). The
|
||||
// puller hands every ask's run a phase reporter in its context; RunAsk and a module's own step bodies call Phase as they
|
||||
// move (fetch and merge, a level's digest, lint, a level's tests), and each phase is logged with the ask's id and its
|
||||
|
||||
+101
-18
@@ -86,11 +86,24 @@ type Puller struct {
|
||||
// mesh-config resolves for `build-hours` takes its place once handed. Outside the window the capacity judged by is 0:
|
||||
// steps running finish, no new one is taken; the puller wakes at the window's next edge. Nil builds at any hour.
|
||||
BuildHours func() (*BuildWindow, string)
|
||||
// Eligible says why this node cannot run the seat's asks at all now, and "" when it can: what the module proves of
|
||||
// its machine and finds missing (mesh-build-image: /dev/net/tun cannot be opened, so buildah's network for a RUN line
|
||||
// cannot be made; 2026-10-28 on ace). Read at every judgement, so it is cheap (a module answers its last look).
|
||||
// While it says a reason the capacity judged by is 0, said with the reason: steps running finish, no new one is
|
||||
// taken, and the asks go to a node that can run them instead of failing here. The module calls Wake when its answer
|
||||
// changes. Nil is always eligible.
|
||||
Eligible func() string
|
||||
// Has says whether this node has a need an ask names (delivery.AskNeeds, issue 890). An ask needing what it lacks is
|
||||
// declined: its step ends refused as declined (delivery.StepDeclined), the message is dropped for its asker to put
|
||||
// again, and the worker it came from is not looked at again until the next pull's wait, so a node lacking the need
|
||||
// does not take the asker's next copy at once. Nil has nothing.
|
||||
Has func(need string) bool
|
||||
// SettingsFile is the module's settings file. With it set the puller wakes whenever the file is written (a file event,
|
||||
// WatchSettings), and reads Capacity from it itself, keeping why it cannot be read (CapacityNote); a parked puller at
|
||||
// capacity 0 therefore wakes when the node-engine refills the file, and a failed config follow is tried again then.
|
||||
SettingsFile string
|
||||
// watching, when set, is called each time the settings file's watch is (re)armed: a test waits on it, not on a sleep.
|
||||
watching func()
|
||||
|
||||
mu sync.Mutex
|
||||
inHand int
|
||||
@@ -101,6 +114,14 @@ type Puller struct {
|
||||
// watched is the capacity the config watch handed last, used in place of Capacity once hasWatched.
|
||||
watched int
|
||||
hasWatched bool
|
||||
// capFollow and hoursFollow are the state of the two config follows: followed once WatchConfig took them, trying
|
||||
// while a start is in flight; followFault is the last failure said, so each failure is said once.
|
||||
capFollowed, capTrying, hoursFollowed, hoursTrying bool
|
||||
followFault string
|
||||
// fileFault is why the settings file's capacity cannot be read, "" when it can.
|
||||
fileFault string
|
||||
// capWhy is why the config watch's last value could not be read, "" when it could.
|
||||
capWhy string
|
||||
// hours is the build window the config watch handed last, and hoursFault why its value cannot be read; used in place
|
||||
// of BuildHours once hasHours.
|
||||
hours *BuildWindow
|
||||
@@ -241,6 +262,21 @@ func (p *Puller) roomNow() (ok bool, capacity, inHand int) {
|
||||
return !p.paused && p.inHand < capacity, capacity, p.inHand
|
||||
}
|
||||
|
||||
// CapacityNote says when the puller takes nothing for want of a capacity: why, and whether it is a fault (a setting that
|
||||
// cannot be read or is refused, so a person must look) and not a choice (a capacity set to 0). "" when it pulls.
|
||||
func (p *Puller) CapacityNote() (why string, fault bool) {
|
||||
c := p.CapacityDetail()
|
||||
if c.Effective > 0 {
|
||||
return "", false
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.fileFault != "" && !p.hasWatched {
|
||||
return "the " + p.Language + " build capacity of " + p.Node + " is 0 because it cannot be read: " + p.fileFault, true
|
||||
}
|
||||
return "the " + p.Language + " build capacity of " + p.Node + " is " + c.Why, p.hasWatched && p.capWhy != ""
|
||||
}
|
||||
|
||||
// CapacityNow is the capacity the puller judges by (CapacityDetail's Effective).
|
||||
func (p *Puller) CapacityNow() int {
|
||||
return p.CapacityDetail().Effective
|
||||
@@ -258,6 +294,15 @@ func (p *Puller) CapacityDetail() delivery.Capacity {
|
||||
switch {
|
||||
case has:
|
||||
configured, from = watched, "mesh-config, as the config watch handed it"
|
||||
case p.SettingsFile != "":
|
||||
n, err := ReadCapacity(p.SettingsFile)
|
||||
configured = n
|
||||
p.mu.Lock()
|
||||
p.fileFault = ""
|
||||
if err != nil {
|
||||
p.fileFault = err.Error()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
case p.Capacity != nil:
|
||||
configured = p.Capacity()
|
||||
}
|
||||
@@ -291,6 +336,13 @@ func (p *Puller) CapacityDetail() delivery.Capacity {
|
||||
c.Why = fmt.Sprintf("%s; within its build hours %s until %s", c.Why, c.BuildHours, w.NextEdge(at).Format("15:04"))
|
||||
}
|
||||
}
|
||||
if p.Eligible != nil {
|
||||
if why := p.Eligible(); why != "" {
|
||||
c.Ineligible = why
|
||||
c.Why = fmt.Sprintf("0, not eligible: %s (otherwise %s)", why, c.Why)
|
||||
c.Effective = 0
|
||||
}
|
||||
}
|
||||
p.sayCapacity(c)
|
||||
return c
|
||||
}
|
||||
@@ -326,7 +378,8 @@ func (p *Puller) sayCapacity(c delivery.Capacity) {
|
||||
p.mu.Lock()
|
||||
same := p.hasJudged && p.judged.Effective == c.Effective && p.judged.Configured == c.Configured && p.judged.ByCPU == c.ByCPU &&
|
||||
p.judged.CoresPerStep == c.CoresPerStep && p.judged.QuotaCores == c.QuotaCores && p.judged.OutsideHours == c.OutsideHours &&
|
||||
p.judged.BuildHours == c.BuildHours && p.judged.BuildHoursFault == c.BuildHoursFault
|
||||
p.judged.BuildHours == c.BuildHours && p.judged.BuildHoursFault == c.BuildHoursFault &&
|
||||
p.judged.Ineligible == c.Ineligible
|
||||
if !same {
|
||||
p.judged, p.hasJudged, p.capWritten = c, true, false
|
||||
}
|
||||
@@ -371,8 +424,8 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
// node kept from building is held by its capacity (0), not by a pause.
|
||||
p.logf("the pause of %s cannot be read: %v; pulling as not paused", p.Node, err)
|
||||
}
|
||||
go p.watchCapacity() // WatchConfigServed waits for Serve, which a module starts after its pull
|
||||
go p.watchHours()
|
||||
p.follow() // WatchConfigServed waits for Serve, which a module starts after its pull
|
||||
go p.watchSettings(ctx)
|
||||
wait := p.PullWait
|
||||
if wait <= 0 {
|
||||
wait = DefaultPullWait
|
||||
@@ -399,6 +452,7 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
case <-edge:
|
||||
}
|
||||
stop()
|
||||
p.follow() // a follow that failed is tried again at the next event
|
||||
_, capacity, inHand = p.roomNow()
|
||||
p.logf("the %s puller on %s wakes: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
|
||||
continue
|
||||
@@ -417,6 +471,7 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-p.wake:
|
||||
p.follow()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
continue
|
||||
@@ -595,7 +650,7 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
}
|
||||
}
|
||||
|
||||
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: g14's
|
||||
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: the laptop's
|
||||
// puller started at capacity 0 sat parked with capacity 2 set, because nothing woke it to read its settings file
|
||||
// again). The value comes as mesh-config resolved it for this node, the event of the change itself: the runtime hands
|
||||
// the value now and then each change its machine's provisioner client says moved, so nothing polls, and the puller does
|
||||
@@ -605,11 +660,18 @@ func (p *Puller) watchCapacity() {
|
||||
if p.WatchConfig == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.capFollowed || p.capTrying {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.capTrying = true
|
||||
p.mu.Unlock()
|
||||
err := p.WatchConfig(CapacitySetting, func(c stdio.ProvisionChange) error {
|
||||
n, why := p.capacityFrom(c)
|
||||
p.mu.Lock()
|
||||
before, had := p.watched, p.hasWatched
|
||||
p.watched, p.hasWatched = n, true
|
||||
p.watched, p.hasWatched, p.capWhy = n, true, why
|
||||
p.mu.Unlock()
|
||||
switch {
|
||||
case why != "":
|
||||
@@ -620,9 +682,31 @@ func (p *Puller) watchCapacity() {
|
||||
p.Wake()
|
||||
return nil
|
||||
})
|
||||
p.followed(&p.capFollowed, &p.capTrying, err, "build capacity")
|
||||
}
|
||||
|
||||
// follow starts each config follow that is not running: at the start, and at every wake after (a file event, a resume, a
|
||||
// step's end), so a follow that failed is tried again by the next event and not given up for good.
|
||||
func (p *Puller) follow() {
|
||||
go p.watchCapacity()
|
||||
go p.watchHours()
|
||||
}
|
||||
|
||||
// followed records the end of one follow's start. A failure is said once per distinct error, with that it is tried again.
|
||||
func (p *Puller) followed(done, trying *bool, err error, what string) {
|
||||
p.mu.Lock()
|
||||
*trying = false
|
||||
*done = err == nil
|
||||
said := err != nil && err.Error() == p.followFault
|
||||
if err != nil {
|
||||
p.logf("the %s build capacity of %s cannot be followed on the bus, so it is read from the settings file when the "+
|
||||
"puller wakes: %v", p.Language, p.Node, err)
|
||||
p.followFault = err.Error()
|
||||
} else {
|
||||
p.followFault = ""
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if err != nil && !said {
|
||||
p.logf("the %s %s of %s cannot be followed on the bus, so it is read from the settings file; the follow is tried "+
|
||||
"again at the next event: %v", p.Language, what, p.Node, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +717,13 @@ func (p *Puller) watchHours() {
|
||||
if p.WatchConfig == nil || p.BuildHours == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.hoursFollowed || p.hoursTrying {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.hoursTrying = true
|
||||
p.mu.Unlock()
|
||||
err := p.WatchConfig(BuildHoursSetting, func(c stdio.ProvisionChange) error {
|
||||
setting := ""
|
||||
var w *BuildWindow
|
||||
@@ -644,7 +735,7 @@ func (p *Puller) watchHours() {
|
||||
why = fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, c.Value)
|
||||
default:
|
||||
var err error
|
||||
if w, err = ParseBuildHours(setting, p.Node); err != nil {
|
||||
if w, err = ParseBuildWindow(setting); err != nil {
|
||||
why = err.Error()
|
||||
}
|
||||
}
|
||||
@@ -657,9 +748,7 @@ func (p *Puller) watchHours() {
|
||||
p.Wake()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logf("the %s build hours of %s cannot be followed on the bus, so they are read from the settings file: %v", p.Language, p.Node, err)
|
||||
}
|
||||
p.followed(&p.hoursFollowed, &p.hoursTrying, err, "build hours")
|
||||
}
|
||||
|
||||
// WatchConfigServed is stdio.WatchConfig once Serve has the runtime's channel (stdio.Served): a build module starts its
|
||||
@@ -674,13 +763,7 @@ func (p *Puller) capacityFrom(c stdio.ProvisionChange) (int, string) {
|
||||
if c.Refused != "" {
|
||||
return 0, "its capacity is no longer answered: " + c.Refused
|
||||
}
|
||||
setting := ""
|
||||
if len(c.Value) > 0 && string(c.Value) != "null" {
|
||||
if err := json.Unmarshal(c.Value, &setting); err != nil {
|
||||
return 0, fmt.Sprintf("its capacity %s is not a string", c.Value)
|
||||
}
|
||||
}
|
||||
n, err := capacityOf(setting, p.Node)
|
||||
n, err := capacityOf(c.Value)
|
||||
if err != nil {
|
||||
return 0, err.Error()
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func (w *configWatch) watch(key string, handle func(stdio.ProvisionChange) error
|
||||
w.asked, w.handle = key, handle
|
||||
value := w.value
|
||||
w.mu.Unlock()
|
||||
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)})
|
||||
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(value)})
|
||||
}
|
||||
|
||||
func (w *configWatch) set(t *testing.T, value string) {
|
||||
@@ -33,7 +33,7 @@ func (w *configWatch) set(t *testing.T, value string) {
|
||||
if handle == nil {
|
||||
t.Fatal("the puller watches no config value")
|
||||
}
|
||||
if err := handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)}); err != nil {
|
||||
if err := handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(value)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -166,7 +168,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 +178,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)
|
||||
}
|
||||
}
|
||||
@@ -282,7 +284,7 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
|
||||
t.Run("config watch wakes", func(t *testing.T) {
|
||||
src, st := NewFakeAsks(), newSteps()
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
w := &configWatch{value: "shanks=3,g14=0"}
|
||||
w := &configWatch{value: "0"}
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "g14", Put: st.put, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { return 0 }, // the settings file, not refilled yet
|
||||
WatchConfig: w.watch,
|
||||
@@ -298,9 +300,74 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
|
||||
if w.asked != CapacitySetting {
|
||||
t.Fatalf("the puller watches %q, want %q", w.asked, CapacitySetting)
|
||||
}
|
||||
w.set(t, "shanks=3,g14=2")
|
||||
w.set(t, "2")
|
||||
waitFor(t, "the ask taken once the capacity moved to 2", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
})
|
||||
t.Run("settings refill wakes a parked puller and retries a failed follow", func(t *testing.T) {
|
||||
// 2026-10-28: a replaced builder held the old "shanks=2,..." in its settings file, its config follow timed out, and
|
||||
// the puller parked at 0 with nothing to wake it when the node-engine refilled the file.
|
||||
src, st := NewFakeAsks(), newSteps()
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
file := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(file, []byte(`{"capacity":"shanks=2,ace=2"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
attempts := 0
|
||||
watch := func(key string, handle func(stdio.ProvisionChange) error) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return errors.New("nats: timeout")
|
||||
}
|
||||
return nil // followed; no value handed yet
|
||||
}
|
||||
armed := make(chan struct{}, 8)
|
||||
var parked atomic.Int32
|
||||
parks := func() int32 { return parked.Load() }
|
||||
logf := func(format string, args ...any) {
|
||||
if strings.Contains(format, "parks") {
|
||||
parked.Add(1)
|
||||
}
|
||||
}
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, PullWait: 10 * time.Millisecond,
|
||||
SettingsFile: file, WatchConfig: watch, Logf: logf, watching: func() { armed <- struct{}{} },
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
waitFor(t, "the parked puller to say why", func() bool { why, fault := p.CapacityNote(); return fault && why != "" })
|
||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
||||
t.Fatal("a node whose capacity cannot be read took an ask")
|
||||
}
|
||||
<-armed // the file watch is in place
|
||||
tmp := file + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(`{"capacity":2}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(tmp, file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "the ask taken once the file was refilled", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
// and the directory itself replaced (IN_IGNORED): the watch is armed again and a later refill still wakes the puller
|
||||
dir := filepath.Dir(file)
|
||||
before := parks()
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-armed // armed again
|
||||
waitFor(t, "the puller parked with no settings file", func() bool { return parks() > before })
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
if err := os.WriteFile(file, []byte(`{"capacity":2}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "the second ask taken after the directory was replaced", func() bool { a, _, _ := src.Fates(); return len(a) == 2 })
|
||||
waitFor(t, "the failed follow tried again", func() bool { mu.Lock(); defer mu.Unlock(); return attempts >= 2 })
|
||||
})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
watch func(string, func(stdio.ProvisionChange) error) error
|
||||
|
||||
+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 a build node's 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,112 @@
|
||||
//go:build linux
|
||||
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const settingsMask = syscall.IN_CLOSE_WRITE | syscall.IN_MOVED_TO | syscall.IN_CREATE
|
||||
|
||||
// watchSettings wakes the puller each time its settings file is written or replaced (inotify on the file's directory, so
|
||||
// a rename over it counts): the event the node-engine's refill of the file is, which a parked puller at capacity 0
|
||||
// otherwise never sees. No timer: it blocks on the kernel's event. When the kernel drops the watch (IN_IGNORED: the
|
||||
// directory was replaced or removed) it is added again, said once; while the directory is missing the nearest existing
|
||||
// ancestor is watched for the directory's return. A watch that cannot start is said once.
|
||||
func (p *Puller) watchSettings(ctx context.Context) {
|
||||
if p.SettingsFile == "" {
|
||||
return
|
||||
}
|
||||
fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC | syscall.IN_NONBLOCK)
|
||||
if err != nil {
|
||||
p.logf("the settings file %s cannot be watched, so a refill of it wakes the puller only at another event: %v", p.SettingsFile, err)
|
||||
return
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "inotify")
|
||||
defer f.Close()
|
||||
dir, base := filepath.Split(p.SettingsFile)
|
||||
dir = filepath.Clean(dir)
|
||||
// arm watches dir, or when it is missing its nearest existing ancestor; it returns the watch descriptor and whether it
|
||||
// is the directory's own.
|
||||
arm := func() (int, bool, error) {
|
||||
wd, err := syscall.InotifyAddWatch(fd, dir, settingsMask)
|
||||
if err == nil {
|
||||
return wd, true, nil
|
||||
}
|
||||
for up := filepath.Dir(dir); ; up = filepath.Dir(up) {
|
||||
wd, aerr := syscall.InotifyAddWatch(fd, up, settingsMask)
|
||||
if aerr == nil {
|
||||
// the directory may have come back between the two calls: look once more, now that the ancestor is watched
|
||||
if own, oerr := syscall.InotifyAddWatch(fd, dir, settingsMask); oerr == nil {
|
||||
return own, true, nil
|
||||
}
|
||||
return wd, false, nil
|
||||
}
|
||||
if up == filepath.Dir(up) {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
wd, own, err := arm()
|
||||
if err != nil {
|
||||
p.logf("the settings file %s cannot be watched, so a refill of it wakes the puller only at another event: %v", p.SettingsFile, err)
|
||||
return
|
||||
}
|
||||
if p.watching != nil {
|
||||
p.watching()
|
||||
}
|
||||
go func() { <-ctx.Done(); f.Close() }()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hit, rearm := false, false
|
||||
for off := 0; off+16 <= n; {
|
||||
ewd := int(int32(binary.LittleEndian.Uint32(buf[off:])))
|
||||
mask := binary.LittleEndian.Uint32(buf[off+4:])
|
||||
l := int(binary.LittleEndian.Uint32(buf[off+12:]))
|
||||
name := buf[off+16 : off+16+l]
|
||||
for i, c := range name {
|
||||
if c == 0 {
|
||||
name = name[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case mask&syscall.IN_IGNORED != 0 && ewd == wd:
|
||||
rearm = true
|
||||
case ewd == wd && own && string(name) == base:
|
||||
hit = true
|
||||
case ewd == wd && !own:
|
||||
rearm = true // something appeared where the directory was: look again
|
||||
}
|
||||
off += 16 + l
|
||||
}
|
||||
if rearm {
|
||||
nwd, nown, err := arm()
|
||||
if err != nil {
|
||||
p.logf("the settings file %s is no longer watched, so a refill of it wakes the puller only at another event: %v", p.SettingsFile, err)
|
||||
return
|
||||
}
|
||||
if own || nown { // said once per loss of the watch on the directory itself
|
||||
if own {
|
||||
p.logf("the watch of the settings file %s was dropped (its directory was replaced); watching again", p.SettingsFile)
|
||||
}
|
||||
}
|
||||
wd, own = nwd, nown
|
||||
if p.watching != nil {
|
||||
p.watching()
|
||||
}
|
||||
hit = true // the file may have been refilled meanwhile
|
||||
}
|
||||
if hit {
|
||||
p.Wake()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package buildstep
|
||||
|
||||
import "context"
|
||||
|
||||
// watchSettings watches nothing off Linux: the puller wakes at its other events.
|
||||
func (p *Puller) watchSettings(ctx context.Context) {}
|
||||
@@ -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,7 @@ package buscompose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -9,6 +10,11 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for
|
||||
@@ -33,7 +39,7 @@ func beyondTheControllers(user string) map[string]bool {
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
for _, s := range append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...) {
|
||||
for _, s := range append(append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...), NodeComposeAdvisories()...) {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
@@ -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,7 +144,23 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
}
|
||||
}
|
||||
|
||||
const SignersBucketName = "mesh-vault_signers"
|
||||
|
||||
func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
// ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole
|
||||
// proposal laid over), and none of its open proposals; it writes neither.
|
||||
reads := strings.Join(NodeComposeReads("laptop"), " ")
|
||||
for bucket, granted := range map[string]bool{delivery.PlacementBucket: true, delivery.PlacementCandidatesBucket: true,
|
||||
delivery.PlacementProposalsBucket: false} {
|
||||
if strings.Contains(reads, "$JS.API.DIRECT.GET.KV_"+bucket+".>") != granted || strings.Contains(reads, "$KV."+bucket+".laptop") {
|
||||
t.Errorf("a node is granted %s: %v, want %v", bucket, !granted, granted)
|
||||
}
|
||||
}
|
||||
// It hears the creation of those two buckets, and of no other.
|
||||
if got := NodeComposeAdvisories(); len(got) != 2 || got[0] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement" ||
|
||||
got[1] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates" {
|
||||
t.Errorf("a node hears the creation of %v", got)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -205,6 +248,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 +315,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 +384,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, "mesh-delivery-assigner": 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...)
|
||||
}
|
||||
+62
-2
@@ -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
|
||||
@@ -894,10 +911,16 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// its own modules reads the other nodes' public records, which its firewall and resolver need (ADR 0547 §2). Read
|
||||
// only: each node still writes its own keys alone.
|
||||
pub = append(pub, readGrants(delivery.HaveBucket, "*."+delivery.HaveNodeWord)...)
|
||||
// And what it composes its own modules from (ADR 0547 §2, ADR 0549 §3): the indexer's placement, modules and
|
||||
// manifests, the builder's builds (the manifest of a build its want names), and mesh-config's resolved values of
|
||||
// And what it composes its own modules from (ADR 0547 §2, ADR 0567 §9): the seat mesh-placement's placement and
|
||||
// candidates, the indexer's modules and 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)
|
||||
@@ -921,6 +944,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// And the backup holder's word that it backed the module up (ADR 0539 clause 16), whichever module holds
|
||||
// node-backup: one event, by its name.
|
||||
sub = append(sub, NodeAgentHears...)
|
||||
// And the creation of the seat mesh-placement's two states it composes from (ADR 0567 §9), so a node agent started
|
||||
// before they exist binds each when the bus says it was made, with no timer; no other bucket's.
|
||||
sub = append(sub, NodeComposeAdvisories()...)
|
||||
// And, while the vault serves it, the vault's move-in in its own name and nobody else's (issue 676): the
|
||||
// values sealed to this machine, which only it opens, handed in sealed to the vault's key for the call.
|
||||
if p.MovesIntoTheVault {
|
||||
@@ -939,6 +965,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 +997,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 +1686,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 +1869,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"
|
||||
|
||||
+40
-5
@@ -311,14 +311,49 @@ func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err
|
||||
// ResolvedBucket is mesh-config's state of resolved values, keyed `<node>.<module>.<key>`.
|
||||
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
|
||||
// caller's principal.
|
||||
// NodeComposeReads is what a node's agent reads to compose its own modules (novox/hq ADR 0547 §2, ADR 0567 §3, §9,
|
||||
// issue 1126): every key of the seat mesh-placement's placement and candidates (both the assigner's states), of the
|
||||
// indexer's 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
|
||||
// proposal laid over (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+".>")...)
|
||||
}
|
||||
|
||||
// NodeComposeAdvisories are the stream-created advisories of the seat mesh-placement's placement and candidates
|
||||
// (ADR 0567 §9): what a node agent hears to bind each once it is made. Those two and no wider.
|
||||
func NodeComposeAdvisories() []string {
|
||||
return []string{StreamCreatedAdvisory(delivery.PlacementBucket), StreamCreatedAdvisory(delivery.PlacementCandidatesBucket)}
|
||||
}
|
||||
|
||||
// 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,12 @@ 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
|
||||
// reads the catalogue's placement alone from then (ADR 0549 §5, 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.
|
||||
|
||||
+56
-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,8 @@ 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 +231,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 +265,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 +456,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 +484,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
|
||||
}
|
||||
+29
-17
@@ -126,23 +126,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
continue
|
||||
}
|
||||
made := by[a.Name]
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
//
|
||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||
// A process the host runs is the module's service, not its tools: the controller declares
|
||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
loads = []string{bin}
|
||||
}
|
||||
}
|
||||
loads := BundleLoads(m, a, run)
|
||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
||||
// it reached it by, and a manifest carrying that address names an installation —
|
||||
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
||||
@@ -715,3 +699,31 @@ func undeliveredBundles(m Manifest) []string {
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
|
||||
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
|
||||
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
|
||||
// module composed as what runs of it reads again when the runtime already dropped it (issue 1126).
|
||||
func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
//
|
||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||
// A process the host runs is the module's service, not its tools: the controller declares
|
||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
loads = []string{bin}
|
||||
}
|
||||
}
|
||||
return loads
|
||||
}
|
||||
|
||||
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
|
||||
func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }
|
||||
|
||||
@@ -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"})},
|
||||
}
|
||||
}
|
||||
@@ -445,6 +445,19 @@ func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
|
||||
t.Fatalf("not typed as a value nobody gave: %v", err)
|
||||
}
|
||||
|
||||
// A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}).
|
||||
withRoute := inConfigForm()
|
||||
withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}}
|
||||
routed, err := parseRaw(t, withRoute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = Resolution{}.composed(routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route")
|
||||
if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(),
|
||||
`notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") {
|
||||
t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err)
|
||||
}
|
||||
|
||||
settingsForm := notifier()
|
||||
settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n"
|
||||
err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm)
|
||||
|
||||
@@ -10,6 +10,7 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -138,6 +139,9 @@ type Rendering struct {
|
||||
// Kept is every operator-sealed secret in the mesh, for a module that `keeps` them. Nil when
|
||||
// nothing on this node keeps them, or the mesh has no operator key.
|
||||
Kept *KeptExport
|
||||
// KeptRuns is, by module, the file of the export as the node runs it, for a composer that holds no export (a node
|
||||
// composing its own: the export is the controller's store's, issue 1126, C6): carried as it runs when Kept is nil.
|
||||
KeptRuns map[string]map[string]any
|
||||
|
||||
// OutwardLinks is the links this machine reported as facing outside it, which the filter is
|
||||
// written around (novox/hq ADR 0140). Empty means the machine has not said, and the mesh
|
||||
@@ -929,6 +933,14 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
// Read by the module as its secrets' owner when it names one: the vault, as its own account (issue 723).
|
||||
first = append(first, ownedBy(m.SecretsOwner, file))
|
||||
} else if runs, ok := with.KeptRuns[m.Module]; m.Keeps != "" && ok {
|
||||
file := make(map[string]any, len(runs))
|
||||
for k, v := range runs {
|
||||
file[k] = v
|
||||
}
|
||||
file["id"] = KeptID()
|
||||
delete(file, "module")
|
||||
first = append(first, file)
|
||||
}
|
||||
if m.Computed != "" {
|
||||
mine, part := generated[m.Module]
|
||||
@@ -1091,6 +1103,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
for _, unsettled := range resources {
|
||||
if pr, ok := unsettled[HandedProvisionField].(PlanProvision); ok {
|
||||
if handing == nil && m.Stubbed {
|
||||
continue // held: its credential file is what runs of it, left out (issue 1126)
|
||||
}
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, pr.Provision)
|
||||
@@ -1115,6 +1130,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
continue
|
||||
}
|
||||
if unsettled[HandedSecretField] != nil {
|
||||
if handing == nil && m.Stubbed {
|
||||
continue // held: its own secret's file is what runs of it, left out (issue 1126)
|
||||
}
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, unsettled[HandedSecretField])
|
||||
@@ -1821,7 +1839,14 @@ func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers [
|
||||
// module's files is no part of it (novox/hq 04-ISSUES/173).
|
||||
values, err := overridden(raw, WithDefaults(m, layers), what)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", what, err)
|
||||
// A required value nothing sets is said in the words of the module's form, as a file's, a unit's and an owner's
|
||||
// are (issue 1126, seed-8: de-spiegel in the config form was refused with ${setting:…} and `settings set`).
|
||||
var unset *UnsetSettingError
|
||||
if errors.As(err, &unset) && unset.Module == "" {
|
||||
unset.Module = m.Module
|
||||
unset.where = fmt.Sprintf("a contribution%s, in %q,", strings.TrimPrefix(what, m.Module+" contributing"), unset.field)
|
||||
}
|
||||
return nil, unsetInConfigWords(fmt.Errorf("%s: %w", what, err), m)
|
||||
}
|
||||
reaches, err := Reaches(m, layers)
|
||||
if err != nil {
|
||||
|
||||
@@ -75,6 +75,19 @@ func deliveryVerbs() []Verb {
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
||||
[]string{"id", "why"})},
|
||||
deliveryRemovedInR6("retire-history"),
|
||||
{Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " +
|
||||
"trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " +
|
||||
"The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " +
|
||||
"is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " +
|
||||
"reason, naming why.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
|
||||
"properties": map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
|
||||
"description": "the held deliveries' ids (owner/repository@commit)"},
|
||||
"why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"},
|
||||
}},
|
||||
// Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere.
|
||||
Optional: true},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package catalogue
|
||||
|
||||
// The node seat node-engine (novox/hq ADR 0563 §1): held on every node by that node's engine (mesh-host), which serves
|
||||
// its verbs on its own bus account. No module claims it and no assignment holds it: what the mesh knows of its holder
|
||||
// is the node's record and the engine's haves (ADR 0563 §1 changes ADR 0110 §6 and §7 for this one seat).
|
||||
//
|
||||
// Its verbs carry no signature (§7): the runtime proves the caller, which matters only for the record. The read-only
|
||||
// ones change nothing or are bounded in the engine's own state (§2); hand-over and access-act perform nothing on the
|
||||
// call, and act only on the operator's warrant to the engine's own ask (§3).
|
||||
|
||||
// EngineSeat is the node seat every node's engine holds.
|
||||
const EngineSeat = "node-engine"
|
||||
|
||||
// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them.
|
||||
func EngineVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "hand-over", Description: "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, " +
|
||||
"§5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, " +
|
||||
"reached through no link) and refuses in words, or asks the operator on their channel at the level approve, " +
|
||||
"binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands " +
|
||||
"it over. One ask is open per path; acts shows where it stands.",
|
||||
Input: schema(map[string]string{"path": "the directory's absolute path, as the engine states it",
|
||||
"why": "why it is asked, in words the operator reads"}, []string{"path"})},
|
||||
{Name: "access-act", Description: "Ask the operator for the take act on a library whose root holds data outside its " +
|
||||
"group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its " +
|
||||
"root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes " +
|
||||
"sets the library's group, group write and setgid under its root. One ask is open per access.",
|
||||
Input: schema(map[string]string{"act": "the act: take", "access": "the access, library:<name>",
|
||||
"why": "why it is asked, in words the operator reads"}, []string{"act", "access"})},
|
||||
{Name: "judge-directories", Description: "Which of this machine's declared directories the engine would refuse to " +
|
||||
"make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "setuid-search", Description: "Throw away the engine's last search for setuid programs and start a full one " +
|
||||
"(novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while " +
|
||||
"one runs, and when one was started within the hour, naming when the next may start.",
|
||||
Input: schema(map[string]string{"why": "why it is asked, in words"}, nil)},
|
||||
{Name: "root-free", Description: "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only " +
|
||||
"on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root " +
|
||||
"without a person, and the login shell's execute not served here. Anything else is no pass, and says why. " +
|
||||
"Answered by this machine's engine alone.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "acts", Description: "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): " +
|
||||
"each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " +
|
||||
"operator's warrant. Read-only.",
|
||||
Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime
|
||||
// proved it and the ask (ADR 0563 §1).
|
||||
var EngineEvents = []string{"asked", "done", "refused"}
|
||||
|
||||
// engineSeat is the seat's row in the closed set.
|
||||
func engineSeat() Seat {
|
||||
return Seat{Name: EngineSeat, Scope: ScopeNode, Serves: EngineVerbs(), Emits: append([]string(nil), EngineEvents...),
|
||||
HeldByTheEngine: true, Decision: "novox/hq ADR 0563"}
|
||||
}
|
||||
+17
-1
@@ -121,7 +121,7 @@ func (r Resolution) ownedPaths() map[string]string {
|
||||
for _, s := range Seats() {
|
||||
for _, sp := range s.Paths {
|
||||
for _, m := range r.Modules {
|
||||
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) {
|
||||
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) && !r.stepsBackHere(m, sp) {
|
||||
owned[sp.Path] = m.Module + " (" + s.Name + ")"
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,22 @@ func (r Resolution) ownedPaths() map[string]string {
|
||||
return owned
|
||||
}
|
||||
|
||||
// stepsBackHere says m's seat path steps back on this machine (novox/hq ADR 0287): it names a seat whose holder here
|
||||
// declares the path too, so that holder owns it, as stepsBack leaves m's own rendering of it out. Without it the owner
|
||||
// told was the last seat in the order of the seat table, which the controller reads sorted by name and a node in the
|
||||
// order compiled (issue 1126, C3: /etc/resolv.conf, node-uplink against node-resolver).
|
||||
func (r Resolution) stepsBackHere(m Manifest, sp SeatPath) bool {
|
||||
if sp.StepsBackFor == "" {
|
||||
return false
|
||||
}
|
||||
for _, o := range r.Modules {
|
||||
if o.Module != m.Module && holdsSeat(o, sp.StepsBackFor) && declaresPath(o, path.Clean(sp.Path)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filesWords is what the holder of node-files is given beside its own words: the node's secret places and the
|
||||
// paths modules own here.
|
||||
func (r Resolution) filesWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -86,6 +87,39 @@ func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
|
||||
if !strings.HasPrefix(owned["/etc/app/app.conf"], "app") {
|
||||
t.Errorf("a placed file is owned by %q, want app", owned["/etc/app/app.conf"])
|
||||
}
|
||||
// Issue 1126, C3: a seat path that steps back for a seat held here is that holder's, whatever the order of the seat
|
||||
// table (the controller reads it sorted by name, a node in the order compiled).
|
||||
resolver := Manifest{Module: "systemd-resolved", Version: "1", Claims: []Claim{{Name: ResolverSeat}},
|
||||
Resources: []map[string]any{{"id": "stub", "type": "file", "path": ResolverFile}}}
|
||||
uplink := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
||||
Resources: []map[string]any{{"id": "resolv", "type": "file", "path": ResolverFile}}}
|
||||
compiled := seats
|
||||
t.Cleanup(func() { seats = compiled })
|
||||
for _, order := range []string{"compiled", "reversed"} {
|
||||
if order == "reversed" {
|
||||
seats = append([]Seat(nil), compiled...)
|
||||
slices.Reverse(seats)
|
||||
}
|
||||
both := Resolution{Node: "anchor", Modules: []Manifest{files, uplink, resolver}}
|
||||
w, err := both.filesWords(files, nil, Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var o map[string]string
|
||||
if err := json.Unmarshal([]byte(w[FilesOwnedPaths]), &o); err != nil || o[ResolverFile] != "systemd-resolved ("+ResolverSeat+")" {
|
||||
t.Errorf("seat table %s: %s is owned by %q, want systemd-resolved (%s)", order, ResolverFile, o[ResolverFile], ResolverSeat)
|
||||
}
|
||||
}
|
||||
seats = compiled
|
||||
// Issue 1126, C5 and seed-8: a module carried as it runs (compose.Carried) owns its manifest's paths (OwnsPaths), as
|
||||
// the controller tells them, and not the paths its running resources are at (a directory the mesh placed).
|
||||
carried := Manifest{Module: "de-spiegel", Version: "1", OwnsPaths: []string{"/var/lib/de-spiegel/server.env"},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/de-spiegel"}}}
|
||||
w, err := Resolution{Node: "anchor", Modules: []Manifest{files, carried}}.filesWords(files, nil, Rendering{})
|
||||
if err != nil || !strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) ||
|
||||
strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel":`) {
|
||||
t.Errorf("a carried module does not own its manifest's paths alone: %v %v", w[FilesOwnedPaths], err)
|
||||
}
|
||||
if w, _ := r.filesWords(app, nil, Rendering{}); w[FilesOwnedPaths] != "" {
|
||||
t.Error("a module not holding node-files is told the owned paths")
|
||||
}
|
||||
|
||||
@@ -185,6 +185,22 @@ func TestAHandedModulesCredentialIssuedForItIsNamedInItsPlanAndNoValueIsSealed(t
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
// Issue 1126 (novox's seed: "gitea: its credential of postgres-database is handed to the provisioner and the module
|
||||
// is not"): held, the module is composed as its stub, with no template and no item, so nothing is handed. Its
|
||||
// credential and its own secret are what runs of it, left out, and the composition is not refused.
|
||||
r := forgeReader(t, true, true)
|
||||
for i := range r.Modules {
|
||||
if r.Modules[i].Module == "bot" {
|
||||
h := *r.Modules[i].Handed
|
||||
h.Templates = nil
|
||||
r.Modules[i].Handed, r.Modules[i].Stubbed, r.Modules[i].Build = &h, true, nil
|
||||
r.Modules[i].Resources = slices.DeleteFunc(r.Modules[i].Resources, func(x map[string]any) bool { return x["type"] != "directory" })
|
||||
}
|
||||
}
|
||||
c, got = composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, SecretID("forge-read")) || strings.Contains(got, NeedID("token")) || c.Plans["bot"].Provisions != nil {
|
||||
t.Fatalf("a held module's handed credential or own secret is composed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACredentialIssuedForAHolderIsSealedAsBeforeWhereItsModuleIsNotHanded(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package catalogue
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// The sources mesh-config says a resolved value came from, in mesh-config_resolved (novox/hq ADR 0348 §9): a key of
|
||||
// it holds {"value": …, "source": …}.
|
||||
const (
|
||||
SourceMesh = "mesh"
|
||||
SourceNode = "node"
|
||||
SourceModule = "module"
|
||||
SourceDefault = "default"
|
||||
SourceUnset = "unset"
|
||||
)
|
||||
|
||||
// LaidSource says whether a value resolved from source is laid over a module's files: mesh config, node config and the
|
||||
// module's own value are; a manifest's default is not, since it is the composition's already and a taken key is laid
|
||||
// only when a layer sets it, never from a default (TakesSettingsField); an unset value is not, since a required one is
|
||||
// refused by the composition by name. **The one rule for every composer** (issue 1126, C2): the controller's config
|
||||
// read and a node composing its own lay the same values, so the two compose the same files.
|
||||
func LaidSource(source string) bool {
|
||||
switch source {
|
||||
case SourceMesh, SourceNode, SourceModule:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LaidValue is one key of mesh-config_resolved read as a composer lays it: its value, and true when its source is laid
|
||||
// (LaidSource) and it holds a value; false for a default, an unset value, a source not known and a value that cannot be
|
||||
// read.
|
||||
func LaidValue(raw []byte) (any, bool) {
|
||||
var r struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) != nil || !LaidSource(r.Source) || len(r.Value) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
var v any
|
||||
if json.Unmarshal(r.Value, &v) != nil {
|
||||
return nil, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -864,6 +864,17 @@ type Manifest struct {
|
||||
// that could only see its own ports would write a rule set that closed everything else.
|
||||
Filtering *Filtering `json:"filtering,omitempty"`
|
||||
|
||||
// OwnsPaths is, for a module composed from what runs of it (compose.Carried: folded into its node's own composition
|
||||
// at a build of the old form, issue 1126, C5), the paths it owns: its manifest's (DeclaredPaths), as the controller
|
||||
// tells the holder of node-files them, in place of its resources', which are what runs (a directory placed at a
|
||||
// path its manifest leaves to the mesh, a binding). Nil for any other module. Never in a manifest's JSON.
|
||||
OwnsPaths []string `json:"-"`
|
||||
|
||||
// Stubbed marks a module composed as its stub (compose.Stub: held on its node, issue 1126): none of its own
|
||||
// composition is applied, so what it hands the provisioner (a credential the vault keeps for it, its own secrets)
|
||||
// is left out of its plan as it runs, never refused for want of a plan. Never in a manifest's JSON.
|
||||
Stubbed bool `json:"-"`
|
||||
|
||||
// Jails are the fail2ban jails this module declares for its own service (novox/hq to-be 31).
|
||||
// Written into whichever node runs the module, the same way `listens` become that node's rules.
|
||||
Jails []Jail `json:"jails,omitempty"`
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package catalogue
|
||||
|
||||
// The seat of placement (novox/hq ADR 0567): which module runs on which node, with its host ports, bindings, the
|
||||
// node's pins and holdings, the config placement and values, and what a person decides of the node, is state on the
|
||||
// bus held by this seat, changed only by a proposal the nodes it touches answer and the operator approves. Held once
|
||||
// for the mesh by the assigner (mesh-delivery-assigner), beside its wants. Its states are named after the holding
|
||||
// module, as every module state is (delivery.PlacementBucket, PlacementProposalsBucket, PlacementCandidatesBucket);
|
||||
// they are the seat's contract, so every reader takes their names from mesh-sdk, and a change of holder is a migration
|
||||
// the new holder's record decides (ADR 0567 §1). Its events are the holder's too, carried on the holder's stream
|
||||
// (delivery.PlacementStream): the names are in delivery (PlacementProposedEvent and the rest).
|
||||
|
||||
// PlacementSeat is the seat the assigner holds for placement.
|
||||
const PlacementSeat = "mesh-placement"
|
||||
|
||||
// The seat's seven verbs (ADR 0567 §10).
|
||||
const (
|
||||
PlacementVerbPlacement = "placement"
|
||||
PlacementVerbProposals = "proposals"
|
||||
PlacementVerbPropose = "propose"
|
||||
PlacementVerbWithdraw = "withdraw"
|
||||
PlacementVerbAgain = "again"
|
||||
PlacementVerbAdopt = "adopt"
|
||||
PlacementVerbHandBack = "hand-back"
|
||||
)
|
||||
|
||||
// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10).
|
||||
// Optional while the holder that serves them is not yet on every node (design 33 §7): the seat is new in the same
|
||||
// delivery as its holder.
|
||||
func placementVerbs() []Verb {
|
||||
changes := map[string]any{"type": "array", "minItems": 1, "description": "the changes, each an object with `op`: " +
|
||||
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
||||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
||||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
||||
"layer, mesh-wide-values, values)",
|
||||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
||||
return []Verb{
|
||||
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
||||
"module is placed on, or every key, each with its revision, and how full the state is against its cap. With " +
|
||||
"history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " +
|
||||
"proposal's changes from its placement-changed event.",
|
||||
Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on",
|
||||
"history": "\"true\": the changes in order, from the stream and the state's revisions"}, nil, "history")},
|
||||
{Name: PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " +
|
||||
"the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " +
|
||||
"answered, its verdict and its ask. Narrowed to one by id.",
|
||||
Input: schema(map[string]string{"id": "one proposal's id"}, nil)},
|
||||
{Name: PlacementVerbPropose, Optional: true, Description: "Propose a change of placement, with why: laid over " +
|
||||
"the current keys and refused at once, naming every finding, when the result cannot be placed (two modules " +
|
||||
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
||||
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
||||
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
||||
"caller, as the runtime proves it.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
||||
"properties": map[string]any{
|
||||
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
||||
"changes": changes,
|
||||
}}},
|
||||
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
||||
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
||||
Input: schema(map[string]string{"id": "the proposal's id", "why": "why, kept in the event"}, []string{"id", "why"})},
|
||||
{Name: PlacementVerbAgain, Optional: true, Description: "Judge an open proposal now with the answers that " +
|
||||
"stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " +
|
||||
"Nothing more: the recovery of a proposal whose judgement or ask was lost.",
|
||||
Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})},
|
||||
{Name: PlacementVerbAdopt, Optional: true, Description: "Take placement over from the controller: its " +
|
||||
"placement-export is proposed whole as the seed, every node's key, which every node must answer as the same. " +
|
||||
"Its Approve is the take-over; at the warrant the export is read again and a changed one is proposed anew.",
|
||||
Input: schema(map[string]string{"why": "why, shown to the operator"}, []string{"why"})},
|
||||
{Name: PlacementVerbHandBack, Optional: true, Description: "The operator's alone, recorded as a hand act: " +
|
||||
"delete every placement key, the history kept, so every node returns to the controller's compositions. " +
|
||||
"Refused after the lease write.",
|
||||
Input: schema(map[string]string{"why": "why, recorded with the hand act"}, []string{"why"})},
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,14 @@ func namesWithoutSteppingBack(s Seat, p string) bool {
|
||||
// as written would pass for a path nobody named.
|
||||
func declaredPaths(m Manifest) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if m.OwnsPaths != nil {
|
||||
// Carried: what its manifest declares, never what runs of it (issue 1126, seed-8: f1-circuits, snake and txt-game
|
||||
// on ace owned their /var/lib paths on the node and none on the controller).
|
||||
for _, p := range m.OwnsPaths {
|
||||
out[p] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
||||
out[path.Clean(p)] = true
|
||||
@@ -136,6 +144,12 @@ func declaredPaths(m Manifest) map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeclaredPaths is every machine path a module's manifest declares it owns (declaredPaths), sorted and never nil: what
|
||||
// a carried module owns (Manifest.OwnsPaths).
|
||||
func DeclaredPaths(m Manifest) []string {
|
||||
return append([]string{}, sortedKeys(declaredPaths(m))...)
|
||||
}
|
||||
|
||||
// declaresPath says whether a module declares one path, as a resource or as a file the mesh renders.
|
||||
func declaresPath(m Manifest, p string) bool { return declaredPaths(m)[p] }
|
||||
|
||||
|
||||
+44
-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,11 @@ 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"},
|
||||
// Placement (novox/hq ADR 0567): which module runs on which node, state on the bus held by the assigner, changed only by
|
||||
// a proposal the nodes answer and the operator approves. Its states and events are the holder module's.
|
||||
{Name: PlacementSeat, Scope: ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"},
|
||||
// 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 +461,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 +536,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 +650,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -43,6 +44,48 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Forty-seven with mesh-placement (novox/hq ADR 0567), serving its seven verbs.
|
||||
if s, ok := SeatNamed(PlacementSeat); !ok || s.Scope != ScopeMesh || !reflect.DeepEqual(VerbNames(s.Serves),
|
||||
[]string{"placement", "proposals", "propose", "withdraw", "again", "adopt", "hand-back"}) {
|
||||
t.Errorf("the placement seat is %v, %v", ok, s)
|
||||
}
|
||||
// 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 +99,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-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1).
|
||||
if len(Seats()) != 49 {
|
||||
t.Errorf("the mesh defines %d seats rather than 49; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ type UnsetSettingError struct {
|
||||
// where says what asks for it — "a file", "a service whose unit", "a directory <id> whose owner" — and
|
||||
// setToday which of the module's keys a layer sets, for saying it again in the config form's words.
|
||||
where, setToday string
|
||||
// field is the key of a contribution whose value asks for it, where the contribution's words are made by its caller.
|
||||
field string
|
||||
}
|
||||
|
||||
func (e *UnsetSettingError) Error() string { return e.said }
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -129,7 +130,36 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
|
||||
// contract. Until issue 696, a layer replaced any served key it named; influxdb on ace was the one
|
||||
// layer relying on it, and serves `${setting:org}` and `${setting:bucket}` since.
|
||||
func Settle(base map[string]any, layers []Layer) (map[string]any, error) {
|
||||
return filledFromSettings(deepCopy(base), layers, "what is served")
|
||||
out, err := filledFromSettings(deepCopy(base), layers, "what is served")
|
||||
if err != nil {
|
||||
// Said as it was, untyped: what is served names no module here to type it by.
|
||||
return nil, errors.New(err.Error())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -169,10 +199,12 @@ func filledFromSettings(merged map[string]any, layers []Layer, what string) (map
|
||||
for _, asked := range settingsUsed(s) {
|
||||
v, set := settingValue(layers, asked)
|
||||
if !set {
|
||||
return nil, fmt.Errorf(
|
||||
// Typed, its module and where left to the caller that knows them (Resolution.composed), so a module in
|
||||
// the config form is refused in its own words (unsetInConfigWords; issue 1126, seed-8).
|
||||
return nil, &UnsetSettingError{Setting: asked, field: key, setToday: orNoSettings(layers), said: fmt.Sprintf(
|
||||
"%s says ${setting:%s} for %q, and nothing sets %q — an operator's value is the "+
|
||||
"assignment's, never the definition's (novox/hq ADR 0112)%s",
|
||||
what, asked, key, asked, orNoSettings(layers))
|
||||
what, asked, key, asked, orNoSettings(layers))}
|
||||
}
|
||||
s = strings.ReplaceAll(s, "${setting:"+asked+"}", plainly(v))
|
||||
}
|
||||
|
||||
@@ -121,6 +121,8 @@ func TestTheVaultsSeatPromisesTheMove(t *testing.T) {
|
||||
"move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin",
|
||||
"generation", "made_at", "call", "sealed", "read_back_to"},
|
||||
"move-asks": {"asks"},
|
||||
// And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key.
|
||||
"own": {"credentials"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
@@ -148,7 +150,7 @@ func TestTheVaultsSeatPromisesTheMove(t *testing.T) {
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "move-asks"}}}}
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "node-key", "move-asks", "own"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving the move was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestTheDeliverySeatHasNoPlanVerbs(t *testing.T) {
|
||||
if _, ok := rows["mark-ready"]; ok {
|
||||
t.Errorf("mark-ready is still promised")
|
||||
}
|
||||
for _, kept := range []string{"deliveries", "show", "groups", "checks", "table", "recheck", "close", "stalled"} {
|
||||
for _, kept := range []string{"deliveries", "show", "groups", "checks", "table", "recheck", "close", "stalled", "settle"} {
|
||||
if v, ok := rows[kept]; !ok || strings.HasPrefix(v.Description, "Removed") {
|
||||
t.Errorf("%s is not kept (ADR 0539 clause 17)", kept)
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
// placement-seed writes the catalogue's first placement files (novox/hq ADR 0549 §4, ADR 0555 §6, issue 1126) from the
|
||||
// answer of the controller's one read-only verb `placement-export`: one `placement/<node>.json` per node, each the
|
||||
// node's placement as the controller's own code read it from its own store, checked as the catalogue's check checks it
|
||||
// (delivery.CheckPlacements) before anything is written. The pull request it makes merges only when its composition check
|
||||
// composes every node equal to what it runs.
|
||||
//
|
||||
// placement-seed -catalogue <checkout of mesh-catalog> < export.json
|
||||
// placement-seed -catalogue <dir> -export export.json
|
||||
//
|
||||
// export.json is the verb's answer as it prints it ({"<node>": <placement>, ...}, or the verb's wrapping with it under
|
||||
// "answer"). It opens no network connection, bus or store; it writes only <dir>/placement/<node>.json, and refuses to
|
||||
// write over a placement file already there. Exit codes: 0 written; 1 the export cannot be read, is refused by the
|
||||
// check, or a file is there; 2 the flags are wrong.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
catalogue := flag.String("catalogue", "", "the checkout of the catalogue to write placement/<node>.json into")
|
||||
export := flag.String("export", "", "the verb's answer; standard input when absent")
|
||||
flag.Parse()
|
||||
if *catalogue == "" || flag.NArg() > 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: placement-seed -catalogue <dir> [-export <file>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
in := io.Reader(os.Stdin)
|
||||
if *export != "" {
|
||||
f, err := os.Open(*export)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
}
|
||||
written, err := Seed(in, *catalogue)
|
||||
for _, path := range written {
|
||||
fmt.Println(path)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "placement-seed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed reads the export and writes each node's placement file under root, answering the paths written. Nothing is
|
||||
// written when the export does not read, the check refuses it, or a placement file is already there.
|
||||
func Seed(export io.Reader, root string) ([]string, error) {
|
||||
raw, err := io.ReadAll(export)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
placements, err := read(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all := func(string) bool { return true }
|
||||
if refused := delivery.CheckPlacements(placements, all, all); len(refused) > 0 {
|
||||
return nil, fmt.Errorf("the export is refused by the catalogue's check: %v", refused)
|
||||
}
|
||||
nodes := make([]string, 0, len(placements))
|
||||
for node := range placements {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
files := map[string][]byte{}
|
||||
for _, node := range nodes {
|
||||
p := placements[node]
|
||||
p.Revision = "" // only the indexer writes a revision, into the key
|
||||
if _, err := delivery.EncodePlacement(p); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", node, err)
|
||||
}
|
||||
p.V = delivery.Version
|
||||
body, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node)))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil, fmt.Errorf("%s is already there; the seed is written once (ADR 0549 §4)", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
files[path] = append(body, '\n')
|
||||
}
|
||||
var written []string
|
||||
for _, node := range nodes {
|
||||
path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node)))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return written, err
|
||||
}
|
||||
if err := os.WriteFile(path, files[path], 0o644); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written = append(written, path)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// read is the export as the verb prints it, or wrapped as a verb's answer (the controller's verbAnswer: its JSON under
|
||||
// "answer").
|
||||
func read(raw []byte) (map[string]delivery.Placement, error) {
|
||||
var wrapped struct {
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Answer) > 0 {
|
||||
raw = wrapped.Answer
|
||||
}
|
||||
var byNode map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &byNode); err != nil {
|
||||
return nil, fmt.Errorf("the export is not {<node>: <placement>}: %w", err)
|
||||
}
|
||||
if len(byNode) == 0 {
|
||||
return nil, errors.New("the export names no node")
|
||||
}
|
||||
out := map[string]delivery.Placement{}
|
||||
for node, body := range byNode {
|
||||
p, err := delivery.DecodePlacement(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", node, err)
|
||||
}
|
||||
if p.Node != node {
|
||||
return nil, fmt.Errorf("the export's placement of %s names the node %q", node, p.Node)
|
||||
}
|
||||
out[node] = p
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Issue 1126 (ADR 0549 §4, ADR 0555 §6): the seed writes one placement file per node of the export, as the catalogue's
|
||||
// check and the indexer read it, from the verb's answer bare or wrapped; it writes nothing for an export the check
|
||||
// refuses, and never over a file already there.
|
||||
func TestTheSeedWritesEachNodesPlacementFileOnce(t *testing.T) {
|
||||
export := `{"answer":{"ace":{"v":1,"node":"ace","revision":"x","decided":{"address":"10.77.0.2"},"modules":[{"module":"plex","ports":{"32400":32400}}]},` +
|
||||
`"novox":{"v":1,"node":"novox","decided":{"hub":true,"address":"10.77.0.1"},"modules":[]}}}`
|
||||
root := t.TempDir()
|
||||
written, err := Seed(strings.NewReader(export), root)
|
||||
if err != nil || len(written) != 2 {
|
||||
t.Fatalf("written %v, %v", written, err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "placement", "ace.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := delivery.DecodePlacement(raw)
|
||||
if err != nil || p.Node != "ace" || p.Revision != "" || p.HostPorts()["plex"][32400] != 32400 {
|
||||
t.Errorf("ace's file reads %+v, %v", p, err)
|
||||
}
|
||||
if _, err := Seed(strings.NewReader(export), root); err == nil || !strings.Contains(err.Error(), "already there") {
|
||||
t.Errorf("a second seed over the files: %v", err)
|
||||
}
|
||||
clash := `{"ace":{"v":1,"node":"ace","decided":{"address":"10.77.0.1"},"modules":[]},"novox":{"v":1,"node":"novox","decided":{"address":"10.77.0.1"},"modules":[]}}`
|
||||
other := t.TempDir()
|
||||
if _, err := Seed(strings.NewReader(clash), other); err == nil || !strings.Contains(err.Error(), "both have the address") {
|
||||
t.Errorf("an export the check refuses: %v", err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(filepath.Join(other, "placement")); len(entries) != 0 {
|
||||
t.Errorf("a refused export wrote %v", entries)
|
||||
}
|
||||
// What the controller's placement-export prints, frombus.Export per node, is what the seed takes: a node with no
|
||||
// module placed included.
|
||||
in := compose.Input{Nodes: []compose.Node{{Name: "solo", Place: compose.Place{Hub: true, Address: "10.77.0.9"}}}}
|
||||
exported, err := frombus.Export(in, "solo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer, _ := json.Marshal(map[string]any{"answer": map[string]delivery.Placement{"solo": exported}})
|
||||
if written, err := Seed(strings.NewReader(string(answer)), t.TempDir()); err != nil || len(written) != 1 {
|
||||
t.Errorf("Export's answer %s was not seeded: %v, %v", answer, written, err)
|
||||
}
|
||||
for _, bad := range []string{`[]`, `{}`, `{"ace":{"v":1,"node":"novox","modules":[]}}`} {
|
||||
if _, err := Seed(strings.NewReader(bad), t.TempDir()); err == nil {
|
||||
t.Errorf("%s was seeded", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
+455
-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,143 @@ 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)
|
||||
}
|
||||
|
||||
// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user
|
||||
// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses
|
||||
// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126).
|
||||
func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
return compositions(in, node, fps, mode, 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{}
|
||||
held := 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
|
||||
}
|
||||
for m, ok := range reading.Nodes[i].Held {
|
||||
held[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 from what runs of it
|
||||
// (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published
|
||||
// ports, the paths it owns) is what runs, 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
|
||||
candidates := map[string]bool{}
|
||||
for m := range inOwn {
|
||||
candidates[m] = true
|
||||
}
|
||||
for m := range held {
|
||||
candidates[m] = true
|
||||
}
|
||||
for m := range candidates {
|
||||
// A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a
|
||||
// composition of its own (its want new, its names still unset): its stub is never what the node's own applies,
|
||||
// and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945).
|
||||
heldHere := held[m] && RunsAnyOf(runsOwn, m)
|
||||
if !folds(m) && !heldHere {
|
||||
continue
|
||||
}
|
||||
man, ok := n.Kept[m]
|
||||
if !ok {
|
||||
man, ok = reading.Catalogue[m]
|
||||
}
|
||||
if !ok || (!NamesBuild(man) && !held[m]) {
|
||||
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] = Carried(man, m, runsOwn)
|
||||
n.Kept = kept
|
||||
}
|
||||
}
|
||||
// A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
|
||||
// (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
|
||||
for i := range reading.Nodes {
|
||||
n := &reading.Nodes[i]
|
||||
if n.Name != node || n.RunsTools == nil {
|
||||
continue
|
||||
}
|
||||
kept := make(map[string]catalogue.Manifest, len(n.Kept))
|
||||
for m, man := range n.Kept {
|
||||
if man.Stubbed && man.RunsAs == "" {
|
||||
man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
|
||||
}
|
||||
kept[m] = 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 +186,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,18 +231,48 @@ 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 held[c.Module] {
|
||||
delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held)
|
||||
continue
|
||||
}
|
||||
if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" {
|
||||
c.Data = len(m.DataItems()) > 0
|
||||
out[key] = c
|
||||
@@ -130,5 +282,278 @@ 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
|
||||
}
|
||||
|
||||
// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names.
|
||||
func RunsAnyOf(runs *delivery.Composed, module string) bool {
|
||||
if runs == nil {
|
||||
return false
|
||||
}
|
||||
for _, raw := range runs.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
if json.Unmarshal(raw, &head) != nil {
|
||||
continue
|
||||
}
|
||||
owner := head.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(head.ID)
|
||||
}
|
||||
if owner == module {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
|
||||
// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
|
||||
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
|
||||
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
|
||||
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
|
||||
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID()
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
var env map[string]string
|
||||
out := map[string][]catalogue.Bundle{}
|
||||
for _, c := range running {
|
||||
for _, raw := range c.Resources {
|
||||
var r resource
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
continue
|
||||
}
|
||||
owner := r.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
switch {
|
||||
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
|
||||
env = r.Env
|
||||
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")):
|
||||
name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID(""))
|
||||
if r.Path == catalogue.BundlePath(owner, name) {
|
||||
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return nil
|
||||
}
|
||||
var words map[string]map[string]string
|
||||
_ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
|
||||
for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
|
||||
module, path, ok := strings.Cut(entry, "=")
|
||||
if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
|
||||
continue
|
||||
}
|
||||
name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
|
||||
bundles := out[module]
|
||||
i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
|
||||
if !ok || i < 0 {
|
||||
continue // no archive of it runs: nothing to load
|
||||
}
|
||||
var own map[string]string
|
||||
for w, v := range words[module] {
|
||||
if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
|
||||
continue // the mesh's to say, composed again
|
||||
}
|
||||
if own == nil {
|
||||
own = map[string]string{}
|
||||
}
|
||||
own[w] = v
|
||||
}
|
||||
bundles[i].Loads = append(bundles[i].Loads, load)
|
||||
bundles[i].Env = own
|
||||
}
|
||||
for _, bundles := range out {
|
||||
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
|
||||
// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
|
||||
// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
|
||||
func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
|
||||
var out []catalogue.Bundle
|
||||
for _, b := range runs {
|
||||
if len(b.Loads) == 0 {
|
||||
for _, built := range man.Bundles {
|
||||
if built.Name == b.Name {
|
||||
b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
|
||||
}
|
||||
}
|
||||
if man.Build != nil {
|
||||
run := catalogue.RunByAResource(man)
|
||||
for _, a := range man.Build.Artifacts {
|
||||
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
|
||||
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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). A module refused in a
|
||||
// node's answer is composed so; a folded one the node runs is Carried.
|
||||
func Stub(m catalogue.Manifest) catalogue.Manifest {
|
||||
// The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8).
|
||||
if m.OwnsPaths == nil {
|
||||
m.OwnsPaths = catalogue.DeclaredPaths(m)
|
||||
}
|
||||
m.Build = nil
|
||||
// What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed).
|
||||
m.Stubbed = true
|
||||
// Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node
|
||||
// refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its
|
||||
// items stay, and the templates they render through: a held module's contributions to other modules' collections
|
||||
// (seat contributions, shell blocks, seat data) still reach their collectors.
|
||||
if m.Handed != nil {
|
||||
h := *m.Handed
|
||||
h.Templates = nil
|
||||
m.Handed = &h
|
||||
}
|
||||
var dirs []map[string]any
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "directory" {
|
||||
dirs = append(dirs, r)
|
||||
}
|
||||
}
|
||||
m.Resources = dirs
|
||||
return m
|
||||
}
|
||||
|
||||
// Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126,
|
||||
// C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their
|
||||
// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it
|
||||
// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The
|
||||
// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes
|
||||
// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and
|
||||
// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the
|
||||
// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them
|
||||
// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that
|
||||
// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none
|
||||
// of is its Stub, as before.
|
||||
func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest {
|
||||
if runs == nil {
|
||||
return Stub(m)
|
||||
}
|
||||
var taken []map[string]any
|
||||
runsAny := false
|
||||
ids := map[string]bool{}
|
||||
for _, raw := range runs.Resources {
|
||||
var r map[string]any
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
owner, _ := r["module"].(string)
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(id)
|
||||
}
|
||||
if owner != module {
|
||||
continue
|
||||
}
|
||||
runsAny = true
|
||||
if typ := r["type"]; typ != "container" && typ != "directory" {
|
||||
continue
|
||||
}
|
||||
r["id"] = strings.TrimPrefix(id, module+".")
|
||||
delete(r, "module")
|
||||
ids[r["id"].(string)] = true
|
||||
taken = append(taken, r)
|
||||
}
|
||||
if !runsAny {
|
||||
return Stub(m)
|
||||
}
|
||||
stub := Stub(m)
|
||||
for _, r := range stub.Resources {
|
||||
if id, _ := r["id"].(string); !ids[id] {
|
||||
taken = append(taken, r)
|
||||
}
|
||||
}
|
||||
stub.Resources = taken
|
||||
return stub
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
+115
-3
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
@@ -58,18 +59,129 @@ 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")
|
||||
}
|
||||
|
||||
// Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
|
||||
// contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
|
||||
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
|
||||
// held module's new build's bundles are not delivered.
|
||||
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
|
||||
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
|
||||
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
|
||||
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
|
||||
store := catalogue.Manifest{Module: "artifacts", Version: "1",
|
||||
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
|
||||
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
|
||||
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
|
||||
newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools"
|
||||
runtimeRuns := func(dropped bool) delivery.Composed {
|
||||
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
|
||||
if dropped {
|
||||
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
|
||||
}
|
||||
return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
|
||||
}
|
||||
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
|
||||
`","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
set func(n *Node) delivery.Composed // and what runs of lamp
|
||||
fps map[string]string
|
||||
dropped bool
|
||||
}{
|
||||
{"folded at a build of the old form", func(n *Node) delivery.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, false},
|
||||
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, true},
|
||||
{"held, its new build not delivered", func(n *Node) delivery.Composed {
|
||||
n.Held = map[string]bool{"lamp": true}
|
||||
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
|
||||
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
|
||||
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
|
||||
return lr
|
||||
}, map[string]string{"lamp": fpFor('1')}, false},
|
||||
} {
|
||||
tools := aMesh(t)
|
||||
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
|
||||
anchor := nodeOf(t, &tools, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
|
||||
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
|
||||
anchor.SealingKey = keyOf(t, "anchor")
|
||||
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
|
||||
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
|
||||
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
var env map[string]any
|
||||
for _, c := range out {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Env map[string]any `json:"env"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
|
||||
env = r.Env
|
||||
}
|
||||
}
|
||||
}
|
||||
if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
|
||||
t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
|
||||
env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"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 proposal's placement candidates (novox/hq ADR 0567 §3, 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; the proposal and the 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 proposal 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, proposal string, revision uint64) delivery.CandidateAnswer {
|
||||
a := delivery.CandidateAnswer{Proposal: proposal, 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 := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps))
|
||||
if err != nil {
|
||||
// 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
|
||||
}
|
||||
// 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]})
|
||||
}
|
||||
heldAsToday(&a, s.HeldToday)
|
||||
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 proposal 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 })
|
||||
heldAsToday(&a, s.HeldToday)
|
||||
a.Verdict = delivery.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
|
||||
// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
|
||||
// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
|
||||
// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
|
||||
// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered.
|
||||
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
|
||||
if len(today) == 0 {
|
||||
return
|
||||
}
|
||||
kept := a.Refusals[:0]
|
||||
for _, r := range a.Refusals {
|
||||
if w, keep := today[r.Module]; keep {
|
||||
r.Today = w
|
||||
a.Held = append(a.Held, r)
|
||||
} else {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
a.Refusals = kept
|
||||
if len(a.Refusals) == 0 {
|
||||
a.Refusals = nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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, ""
|
||||
}
|
||||
|
||||
// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's
|
||||
// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the
|
||||
// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid
|
||||
// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in
|
||||
// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single
|
||||
// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the
|
||||
// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a
|
||||
// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers
|
||||
// every stand-in for the caller to judge (Answer).
|
||||
//
|
||||
// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it
|
||||
// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others
|
||||
// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own
|
||||
// composition is in out: the node keeps what it last applied.
|
||||
//
|
||||
// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held
|
||||
// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer,
|
||||
// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what
|
||||
// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the
|
||||
// build it runs where one is known (ran: a composition of its own running at another build than it is composed at),
|
||||
// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision
|
||||
// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account,
|
||||
// and the hold says so.
|
||||
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string,
|
||||
ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
mode := compose.Foreseeing
|
||||
if applied {
|
||||
mode = compose.Reading
|
||||
}
|
||||
runs := map[string]*delivery.Composed{}
|
||||
for i := range running {
|
||||
if m := running[i].Module; m != "" {
|
||||
runs[m] = &running[i]
|
||||
}
|
||||
}
|
||||
src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered
|
||||
left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut)
|
||||
hold := func(m, why, leave string) {
|
||||
if _, done := failed[m]; !done {
|
||||
failed[m] = why // a held module tried again keeps the words it was first held for
|
||||
}
|
||||
if _, ok := src[m]; !ok {
|
||||
man, ok := manifestOn(in, node, m)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ran != nil {
|
||||
if was, ok := ran(m); ok {
|
||||
man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens
|
||||
}
|
||||
}
|
||||
src[m] = man
|
||||
}
|
||||
if leave != "" {
|
||||
if left[m] == nil {
|
||||
left[m] = map[string]bool{}
|
||||
}
|
||||
left[m][leave] = true
|
||||
if leave != leaveAll {
|
||||
failed[m] += "; composed without what it contributes to " + leave + ", which does not compose"
|
||||
}
|
||||
}
|
||||
in = heldAs(in, node, m, src[m], left[m], runs[m])
|
||||
}
|
||||
for _, m := range sortedKeys(failed) {
|
||||
hold(m, failed[m], "")
|
||||
}
|
||||
for {
|
||||
out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode)
|
||||
if err != nil {
|
||||
module, leave := culprit(in, node, fps, mode, failed, err)
|
||||
if module == "" {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
hold(module, err.Error(), leave)
|
||||
continue
|
||||
}
|
||||
again := false
|
||||
if applied && len(stood) > 0 {
|
||||
placed := map[string]bool{}
|
||||
for _, m := range assignedOn(in, node) {
|
||||
placed[m] = true
|
||||
}
|
||||
of := map[string][]string{}
|
||||
var nodes []string
|
||||
for _, x := range stood {
|
||||
if n, m := standsInFor(x, node); n == node && placed[m] {
|
||||
of[m] = append(of[m], x)
|
||||
} else {
|
||||
nodes = append(nodes, x)
|
||||
}
|
||||
}
|
||||
for _, m := range sortedKeys(of) {
|
||||
if _, done := failed[m]; !done {
|
||||
hold(m, "stands in for "+strings.Join(of[m], ", "), "")
|
||||
again = true
|
||||
}
|
||||
}
|
||||
if !again && len(nodes) > 0 {
|
||||
return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", "))
|
||||
}
|
||||
}
|
||||
if !again {
|
||||
break
|
||||
}
|
||||
}
|
||||
skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held })
|
||||
return out, skipped, stood, nil
|
||||
}
|
||||
|
||||
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); any other word leaves out
|
||||
// what it contributes to that provision alone.
|
||||
const leaveAll = "*"
|
||||
|
||||
// assignedOn is the modules placed on node, in placement order.
|
||||
func assignedOn(in compose.Input, node string) []string {
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
return n.Assigned
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// manifestOn is the manifest node composes module from: its Kept, else the catalogue's.
|
||||
func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) {
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
if man, ok := n.Kept[module]; ok {
|
||||
return man, true
|
||||
}
|
||||
}
|
||||
}
|
||||
man, ok := in.Catalogue[module]
|
||||
return man, ok
|
||||
}
|
||||
|
||||
// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
|
||||
// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
|
||||
// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
|
||||
// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
|
||||
// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
|
||||
// one module's). Modules already refused are passed over for their stub.
|
||||
func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) {
|
||||
assigned := assignedOn(in, node)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
changes := func(m, leave string) bool {
|
||||
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
|
||||
return again == nil || again.Error() != err.Error()
|
||||
}
|
||||
for _, m := range order {
|
||||
if _, done := failed[m]; done {
|
||||
continue
|
||||
}
|
||||
if changes(m, "") {
|
||||
return m, ""
|
||||
}
|
||||
}
|
||||
for _, m := range order {
|
||||
man, ok := manifestOn(in, node, m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// One already held is tried again too: held for its build, its stub may still refuse (its own secret never
|
||||
// issued, issue 1126). A provision named in the error is tried first, each at one more composition.
|
||||
var to []string
|
||||
for p := range man.Contributes {
|
||||
to = append(to, p)
|
||||
}
|
||||
for p := range man.ContributesMany {
|
||||
if _, both := man.Contributes[p]; !both {
|
||||
to = append(to, p)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(to, func(i, j int) bool {
|
||||
return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j])
|
||||
})
|
||||
for _, p := range to {
|
||||
if changes(m, p) {
|
||||
return m, p
|
||||
}
|
||||
}
|
||||
if isBare(man) {
|
||||
continue
|
||||
}
|
||||
if changes(m, leaveAll) {
|
||||
return m, leaveAll
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll).
|
||||
func isBare(m catalogue.Manifest) bool {
|
||||
return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0
|
||||
}
|
||||
|
||||
// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node
|
||||
// composes it from, leave left out of its offer: a culprit's try.
|
||||
func stubbed(in compose.Input, node, module, leave string) compose.Input {
|
||||
man, ok := manifestOn(in, node, module)
|
||||
if !ok {
|
||||
return in
|
||||
}
|
||||
var left map[string]bool
|
||||
if leave != "" {
|
||||
left = map[string]bool{leave: true}
|
||||
}
|
||||
return heldAs(in, node, module, man, left, nil)
|
||||
}
|
||||
|
||||
// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of
|
||||
// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of
|
||||
// its offer: a provision's contributions, or with leaveAll everything it contributes and its own secrets, none of a
|
||||
// held module's own composition being applied, so what it has not been issued (restic's repository on a node it never
|
||||
// ran on) is no refusal of the rest.
|
||||
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input {
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
for i := range out.Nodes {
|
||||
if out.Nodes[i].Name != node {
|
||||
continue
|
||||
}
|
||||
if left[leaveAll] {
|
||||
man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil
|
||||
} else if len(left) > 0 {
|
||||
contributes := map[string]map[string]any{}
|
||||
for p, v := range man.Contributes {
|
||||
if !left[p] {
|
||||
contributes[p] = v
|
||||
}
|
||||
}
|
||||
many := maps.Clone(man.ContributesMany)
|
||||
for p := range left {
|
||||
delete(many, p)
|
||||
}
|
||||
man.Contributes, man.ContributesMany = contributes, many
|
||||
}
|
||||
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.Carried(man, module, runs)
|
||||
out.Nodes[i].Kept = kept
|
||||
held := make(map[string]bool, len(out.Nodes[i].Held)+1)
|
||||
for k, v := range out.Nodes[i].Held {
|
||||
held[k] = v
|
||||
}
|
||||
held[module] = true
|
||||
out.Nodes[i].Held = held
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"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, "p-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)
|
||||
}
|
||||
// Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
|
||||
// refused module held today the verdict is no refusal, and each is named under Held.
|
||||
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
|
||||
t.Errorf("held as today: %+v", today)
|
||||
}
|
||||
// A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
|
||||
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
|
||||
t.Errorf("held as today in part: %+v", part)
|
||||
}
|
||||
// No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold.
|
||||
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
|
||||
{Module: "mailu", Why: "no build"}}}); true {
|
||||
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
|
||||
if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
|
||||
t.Errorf("held as today spreads: %+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, compose.Foreseeing, 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, _, held, err := Compose(ctx, b, Mesh{}, scope, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
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, "p-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, "p-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, "p-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 _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil ||
|
||||
!strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
|
||||
t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
|
||||
}
|
||||
|
||||
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
|
||||
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
|
||||
// stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file
|
||||
// a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export.
|
||||
listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` +
|
||||
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` +
|
||||
`{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` +
|
||||
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
|
||||
nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`)
|
||||
vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault")
|
||||
for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p.Modules {
|
||||
if p.Modules[i].Module == "spiegel" {
|
||||
p.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
rec := b.records["n1"]
|
||||
rec.OutwardLinks = []string{"eth0"}
|
||||
b.records["n1"] = rec
|
||||
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")}
|
||||
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
|
||||
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`)
|
||||
running = nil
|
||||
for _, c := range base {
|
||||
switch c.Module {
|
||||
case "":
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...),
|
||||
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)+`","ports":["10.77.0.1:20005:8080"]}`),
|
||||
json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`))
|
||||
case "vault":
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile)
|
||||
}
|
||||
running = append(running, c)
|
||||
}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("the node does not compose itself with spiegel carried: %v", err)
|
||||
}
|
||||
var rules string
|
||||
var keptComposed json.RawMessage
|
||||
for _, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
switch r.ID {
|
||||
case "nft." + catalogue.FilteringID():
|
||||
rules = r.Content
|
||||
case "vault." + catalogue.KeptID():
|
||||
keptComposed = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") {
|
||||
t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules)
|
||||
}
|
||||
// The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue
|
||||
// 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and
|
||||
// files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools'
|
||||
// MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is
|
||||
// composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container).
|
||||
for _, c := range running {
|
||||
if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) {
|
||||
t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths)
|
||||
}
|
||||
}
|
||||
f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f1, err := catalogue.ParseManifest(f1raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}}
|
||||
if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want)
|
||||
}
|
||||
// Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node:
|
||||
// "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it
|
||||
// contributes to other modules' collections still reaches them.
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
handed *catalogue.Handed
|
||||
items []catalogue.Item
|
||||
}{
|
||||
{"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"},
|
||||
Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}},
|
||||
{"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}},
|
||||
} {
|
||||
kept := f1
|
||||
kept.Handed, kept.Items = row.handed, row.items
|
||||
st := compose.Stub(kept)
|
||||
if st.Handed != nil && st.Handed.Templates != nil {
|
||||
t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates)
|
||||
}
|
||||
if !reflect.DeepEqual(st.Items, row.items) {
|
||||
t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items)
|
||||
}
|
||||
if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) {
|
||||
t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items)
|
||||
}
|
||||
}
|
||||
var k1, k2 any
|
||||
_ = json.Unmarshal(keptComposed, &k1)
|
||||
_ = json.Unmarshal(keptFile, &k2)
|
||||
if !reflect.DeepEqual(k1, k2) {
|
||||
t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile)
|
||||
}
|
||||
|
||||
// (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs
|
||||
// de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values
|
||||
// for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing
|
||||
// praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is
|
||||
// refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread
|
||||
// to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes
|
||||
// without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does.
|
||||
spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`)
|
||||
mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel")
|
||||
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
|
||||
refused = nil
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) {
|
||||
t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why)
|
||||
}
|
||||
}
|
||||
if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) {
|
||||
t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
|
||||
}
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) {
|
||||
t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a)
|
||||
}
|
||||
}
|
||||
|
||||
// (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It
|
||||
// composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was
|
||||
// marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which
|
||||
// receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it
|
||||
// removes nothing of it.
|
||||
spiegelRuns := []json.RawMessage{
|
||||
json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`),
|
||||
json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`),
|
||||
json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
|
||||
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])}
|
||||
in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest {
|
||||
m.Contributes, m.ContributesMany = nil, nil
|
||||
return m
|
||||
}(in.Nodes[0].Kept["de-spiegel"])
|
||||
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
running = nil
|
||||
for _, c := range base {
|
||||
if c.Module == "" {
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...)
|
||||
}
|
||||
running = append(running, c)
|
||||
}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps)
|
||||
if err != nil {
|
||||
t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err)
|
||||
}
|
||||
if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") {
|
||||
t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
|
||||
}
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m {
|
||||
t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
|
||||
}
|
||||
}
|
||||
carries := func(c delivery.Composed, what string) {
|
||||
t.Helper()
|
||||
ownRes, _ := json.Marshal(c.Resources)
|
||||
for _, raw := range spiegelRuns {
|
||||
var r struct{ ID string }
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) {
|
||||
t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
carries(got[ownKey], "folded")
|
||||
// (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form,
|
||||
// so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried
|
||||
// in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its
|
||||
// container, files and units (issue 1945 by a new path).
|
||||
fps["de-spiegel"] = fp("7")
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
|
||||
if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") {
|
||||
t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held)
|
||||
}
|
||||
spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"])
|
||||
if _, ok := got[spiegelKey]; ok {
|
||||
t.Errorf("held de-spiegel's own composition is handed")
|
||||
}
|
||||
carries(got[ownKey], "leaving the fold")
|
||||
|
||||
// (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others
|
||||
// compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own
|
||||
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which
|
||||
// reads what site listens on, composes with it (issue 1960: a hold never spreads).
|
||||
site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
|
||||
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` +
|
||||
`{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`)
|
||||
b = mk("lemurs", overlay.Name, "site", "nft")
|
||||
for m, raw := range map[string][]byte{"site": site, "nft": nft} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p.Modules {
|
||||
if p.Modules[i].Module == "site" {
|
||||
p.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
rec = b.records["n1"]
|
||||
rec.OutwardLinks = []string{"eth0"}
|
||||
b.records["n1"] = rec
|
||||
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")}
|
||||
siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"])
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" {
|
||||
t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held)
|
||||
}
|
||||
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") {
|
||||
t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules)
|
||||
}
|
||||
for key, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) {
|
||||
t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
// (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and
|
||||
// app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres
|
||||
// consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them").
|
||||
provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1))
|
||||
app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
|
||||
b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
|
||||
for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
|
||||
b2.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p2 := b2.placements["n1"]
|
||||
p2.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p2.Modules {
|
||||
if p2.Modules[i].Module == "site" {
|
||||
p2.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b2.placements["n1"] = p2
|
||||
b2.records["n1"] = rec
|
||||
b2.held = true
|
||||
fps["app"] = fp("9")
|
||||
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
appKey, _ := delivery.ComposedKey("n1", "app", fps["app"])
|
||||
if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
|
||||
t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
|
||||
}
|
||||
if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") {
|
||||
t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
|
||||
}
|
||||
delete(fps, "app")
|
||||
// A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's
|
||||
// routes, its names unset, leave the proxy without them, and its smtp account stays with mailu.
|
||||
withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{},
|
||||
Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}}
|
||||
feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}},
|
||||
OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}}
|
||||
for _, row := range []struct {
|
||||
left map[string]bool
|
||||
route, smtp, own bool
|
||||
}{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveAll: true}, false, false, false}} {
|
||||
k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"]
|
||||
if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own {
|
||||
t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets)
|
||||
}
|
||||
}
|
||||
// (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its
|
||||
// manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the
|
||||
// build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
|
||||
// that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090.
|
||||
moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
|
||||
b.manifests[delivery.ManifestDigest(moved)] = moved
|
||||
b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
|
||||
for i := range p.Modules {
|
||||
p.Modules[i].Ports = nil
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`)
|
||||
siteRuns.FP = fp("7")
|
||||
b.manifests[delivery.ManifestDigest(site)] = site
|
||||
b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" {
|
||||
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held)
|
||||
}
|
||||
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") {
|
||||
t.Errorf("the filter drops what held site last offered (8080): %s", rules)
|
||||
}
|
||||
for key, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
if strings.Contains(string(raw), "dport 9090") {
|
||||
t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, "p-h1", 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)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Export is one node's placement file as the composition input the controller fills says it (novox/hq ADR 0555 §6,
|
||||
// issue 1126): the inverse of Input for what a person decides, so the controller's one read-only verb
|
||||
// `placement-export` answers, from its own store by its own code, exactly what the seed writes into the catalogue.
|
||||
// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
|
||||
// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export`
|
||||
// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay
|
||||
// stable for the same store, since adopt checks its digest again at the warrant.
|
||||
// What the machine observes (its record) is not in it.
|
||||
func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
var n *compose.Node
|
||||
@@ -24,8 +25,8 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
if n == nil {
|
||||
return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node)
|
||||
}
|
||||
// At the version the placement file is read at, with a list of modules even when none is placed: the export is the
|
||||
// file the seed writes (cmd/placement-seed), and the reader refuses a value of version 0 or without its modules.
|
||||
// At the version a placement key is read at, with a list of modules even when none is placed: the export is the
|
||||
// seed's value, and the reader refuses a value of version 0 or without its modules.
|
||||
p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{
|
||||
Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address,
|
||||
PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount,
|
||||
|
||||
+502
-47
@@ -2,28 +2,31 @@
|
||||
// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126).
|
||||
//
|
||||
// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its
|
||||
// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the assigner's composition check for every
|
||||
// node (ADR 0539 §6's check, ADR 0547 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential
|
||||
// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a
|
||||
// proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential
|
||||
// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads.
|
||||
//
|
||||
// **What it reads, and from whom.**
|
||||
// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the
|
||||
// indexer's state `placement`, one key per node (ADR 0549 §2);
|
||||
// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1);
|
||||
// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and
|
||||
// accounts): the same placement key, Placement.Decided;
|
||||
// - 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
|
||||
// read** (each a later subtask of issue 1126): licences (none is placed today) and core boxes. Without one
|
||||
// the composition stands in for it, and Compose refuses a composition that stands in for anything (compose.ErrStoodIn):
|
||||
// a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is
|
||||
// the composition stands in for it, and Compose holds the module it stands in for (or refuses the node, for a stand-in
|
||||
// of no module placed there; compose.ErrStoodIn): a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is
|
||||
// always empty.
|
||||
package frombus
|
||||
|
||||
@@ -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,105 @@ 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 placement of the nodes it names: a proposal's candidates
|
||||
// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with
|
||||
// what it runs (ADR 0567 §3); 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
|
||||
// HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with
|
||||
// how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only
|
||||
// behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126).
|
||||
HeldToday map[string]string
|
||||
// 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 +233,20 @@ 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 := parser(mesh)
|
||||
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]
|
||||
@@ -144,15 +262,17 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
|
||||
|
||||
// The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes
|
||||
// with mesh-sdk and always wins, as the controller's providedModules and the grants part have it.
|
||||
provided, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
for _, name := range delivery.ProvidedModules() {
|
||||
raw, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
var private catalogue.Manifest
|
||||
if err := json.Unmarshal(raw, &private); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
|
||||
}
|
||||
in.Catalogue[name] = private
|
||||
}
|
||||
var private catalogue.Manifest
|
||||
if err := json.Unmarshal(provided, &private); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the private network's manifest mesh-sdk provides: %w", err)
|
||||
}
|
||||
in.Catalogue[overlay.Name] = private
|
||||
|
||||
var missing []string
|
||||
for _, node := range nodesOf(placements, records) {
|
||||
@@ -172,6 +292,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 +308,44 @@ 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
|
||||
// What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126).
|
||||
n.RunsTools = compose.ToolsRunning(s.Running)
|
||||
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 {
|
||||
// The operator export as the vault runs it: the controller composes it from its store, which the node
|
||||
// does not read; until the export is on the bus, the node carries the file it runs (issue 1126, C6:
|
||||
// mesh-vault.kept on novox, "in what runs, and not composed").
|
||||
if m := s.Running[i].Module; m != "" {
|
||||
if kept := keptOf(s.Running[i]); kept != nil {
|
||||
if in.KeptRuns == nil {
|
||||
in.KeptRuns = map[string]map[string]map[string]any{}
|
||||
}
|
||||
if in.KeptRuns[s.Node] == nil {
|
||||
in.KeptRuns[s.Node] = map[string]map[string]any{}
|
||||
}
|
||||
in.KeptRuns[s.Node][m] = kept
|
||||
}
|
||||
continue
|
||||
}
|
||||
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 +376,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.
|
||||
@@ -240,25 +399,199 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// Compose is the node's compositions over the bus (ADR 0547 §2): Input for s, then compose.CompositionsApplied at the
|
||||
// fingerprints fps gives each module: composed in the Reading mode as the controller composes them, and refused when it
|
||||
// stands in for anything the input does not carry (compose.ErrStoodIn), since the node applies what it composed.
|
||||
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (map[string]delivery.Composed, []string, error) {
|
||||
in, err := Input(ctx, r, mesh, s)
|
||||
// Compose is the node's compositions over the bus (ADR 0547 §2): input for s, then composed at the fingerprints fps
|
||||
// gives each module in the Reading mode, as the controller composes them, by the one rule its answer to a candidate
|
||||
// follows too (composeEach, issue 1126): a module refused alone (its build unread or not taken in, its composition
|
||||
// refused, a stand-in for a value of its own the input does not carry, compose.ErrStoodIn) is held, named in held with
|
||||
// why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a
|
||||
// node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served
|
||||
// elsewhere, a stand-in for the node's own) refuses the node, err.
|
||||
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) {
|
||||
held = map[string]string{}
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() })
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, held, err
|
||||
}
|
||||
// The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5).
|
||||
asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, held, err
|
||||
}
|
||||
if len(asConsumer)+len(asProvider) > 0 {
|
||||
if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
|
||||
return nil, nil, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)
|
||||
return nil, nil, held, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)
|
||||
}
|
||||
}
|
||||
return compose.CompositionsApplied(in, s.Node, fps)
|
||||
out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held, ranOf(ctx, r, mesh, s.Running, fps))
|
||||
return out, skipped, held, err
|
||||
}
|
||||
|
||||
// parser reads a manifest as the indexer does, against the mesh's predefined keys when given.
|
||||
func parser(mesh Mesh) func([]byte) (catalogue.Manifest, error) {
|
||||
return func(raw []byte) (catalogue.Manifest, error) {
|
||||
if mesh.Predefined != nil {
|
||||
return catalogue.ParseManifestWith(raw, mesh.Predefined)
|
||||
}
|
||||
return catalogue.ParseManifestForInputs(raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ranOf is what a module last offered the others, read when it is held (composeEach, issue 1960): its manifest at the
|
||||
// build its own composition runs at (running), where that is another build than the one it is composed at (fps) and
|
||||
// the builder holds it done. False where nothing is known of it (it runs no composition of its own, it runs the build
|
||||
// it is composed at, or its build is unread): the manifest it is composed from is its offer then. Only what it says to
|
||||
// others is taken of it, which needs no take-in of its artifacts.
|
||||
func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) {
|
||||
parse := parser(mesh)
|
||||
return func(module string) (catalogue.Manifest, bool) {
|
||||
for _, c := range running {
|
||||
if c.Module != module || c.FP == "" || c.FP == fps[module] {
|
||||
continue
|
||||
}
|
||||
b, err := r.Build(ctx, module, c.FP)
|
||||
if err != nil || b == nil || b.State != delivery.BuildDone || b.Manifest == "" {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
raw, err := r.Manifest(ctx, b.Manifest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
man, err := parse(raw)
|
||||
return man, err == nil
|
||||
}
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -283,16 +616,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 +633,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 +761,53 @@ 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"
|
||||
}
|
||||
|
||||
// keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil.
|
||||
func keptOf(c delivery.Composed) map[string]any {
|
||||
for _, raw := range c.Resources {
|
||||
var r map[string]any
|
||||
if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+catalogue.KeptID() {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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,59 @@ 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
|
||||
// held answers every credential asked as held, sealed to the consumer.
|
||||
held bool
|
||||
}
|
||||
|
||||
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,8 +91,28 @@ 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) {
|
||||
return nil, nil, nil
|
||||
func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []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)
|
||||
}
|
||||
}
|
||||
var out []compose.Credential
|
||||
var issued []compose.Issued
|
||||
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
|
||||
if b.held {
|
||||
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
|
||||
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
|
||||
}
|
||||
}
|
||||
return out, issued, nil
|
||||
}
|
||||
|
||||
func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
@@ -68,6 +133,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 +230,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)
|
||||
}
|
||||
@@ -167,7 +245,10 @@ func TestInput(t *testing.T) {
|
||||
// The same composer over both: the compositions from the bus are the store's, byte for byte.
|
||||
fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64),
|
||||
"media": "src2:" + strings.Repeat("c", 64)}
|
||||
fromBus, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps)
|
||||
fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -179,6 +260,233 @@ 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, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
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, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
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, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runsNow []delivery.Composed
|
||||
for _, c := range runs {
|
||||
runsNow = append(runsNow, c)
|
||||
}
|
||||
objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
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.Proposal == "p-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, "p-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 +523,203 @@ 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 out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) {
|
||||
t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held)
|
||||
} else {
|
||||
for key, c := range out {
|
||||
if c.Module == "restic" {
|
||||
// Its stub's composition, applied, would remove what restic runs: a held module keeps its last one.
|
||||
t.Errorf("held restic is composed as %s, want none of its own composition handed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
builtBus := movedMesh(true)
|
||||
fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
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 _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") {
|
||||
t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held)
|
||||
}
|
||||
// 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 _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 {
|
||||
t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held)
|
||||
}
|
||||
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 +727,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}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user