Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2ec8b6b2f | ||
|
|
530d8d3007 | ||
|
|
16bc0dcf3e | ||
|
|
044ead7db8 | ||
|
|
78c9ec8817 | ||
|
|
307b6234e6 | ||
|
|
dbcaa29a1a |
@@ -37,6 +37,11 @@ type AskSteps struct {
|
|||||||
// Has says whether this node has a need a package's tests declare (delivery.AskNeeds, issue 890); a package needing
|
// Has says whether this node has a need a package's tests declare (delivery.AskNeeds, issue 890); a package needing
|
||||||
// what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing.
|
// what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing.
|
||||||
Has func(need string) bool
|
Has func(need string) bool
|
||||||
|
// Narrate says an event on the module's own stream (stdio.Emit) and Module is the module's name: a failed test's whole
|
||||||
|
// output is said on the module's log stream while the ask runs, and the step's result names where (narrate). Nil
|
||||||
|
// says nothing, and the whole output is dropped from the result all the same.
|
||||||
|
Narrate func(event string, body any) error
|
||||||
|
Module string
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
|
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
|
||||||
@@ -221,6 +226,7 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
r = s.stepLog(ctx).narrate(r, s.Logf)
|
||||||
la.Tests = append(la.Tests, r)
|
la.Tests = append(la.Tests, r)
|
||||||
var t struct {
|
var t struct {
|
||||||
Passed bool `json:"passed"`
|
Passed bool `json:"passed"`
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -291,4 +293,65 @@ func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
|
|||||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||||
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
|
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The operator, 2026-10-30: a failure's whole output is said on the module's log stream under the run and the step,
|
||||||
|
// the result names where and holds no whole output, and the log reads back whole, in pages, its lines in order.
|
||||||
|
var lines []string
|
||||||
|
for i := range 3000 {
|
||||||
|
lines = append(lines, fmt.Sprintf("line %04d %s", i, strings.Repeat("x", 40)))
|
||||||
|
}
|
||||||
|
var said []stdio.StreamEntry
|
||||||
|
s = r.steps()
|
||||||
|
s.Digest, s.Module = nil, "mesh-build-node"
|
||||||
|
s.Narrate = func(event string, body any) error {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
said = append(said, stdio.StreamEntry{Sequence: uint64(len(said) + 1), Event: event, Body: b})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.Test = func(ctx context.Context, args map[string]any) (any, error) {
|
||||||
|
return map[string]any{"passed": false, "failed": []Failure{{Package: "b", Test: "t", Cause: "boom", Output: "boom", Full: strings.Join(lines, "\n")}}}, nil
|
||||||
|
}
|
||||||
|
r.fetch["modules"] = []string{"b"}
|
||||||
|
ctx := context.WithValue(context.Background(), phaseKey{}, &phases{step: delivery.Step{ID: "s1", Node: "ace"}, run: "r1"})
|
||||||
|
out, err = RunAsk(ctx, s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var res struct{ Failed []Failure }
|
||||||
|
_ = json.Unmarshal(out.(delivery.CheckAnswer).Levels[0].Tests[0], &res)
|
||||||
|
if f := res.Failed[0]; f.Full != "" || f.Step != "s1" || f.Module != "mesh-build-node" || f.Machine != "ace" || f.Run != "r1" || len(said) < 2 || said[0].Event != "log.r1.s1" {
|
||||||
|
t.Fatalf("failure %+v, %d events (first %+v): want its address, no whole output, and its log in several events", f, len(said), said[0].Event)
|
||||||
|
}
|
||||||
|
read := func(events string, from uint64, limit int) (*stdio.StreamPage, error) {
|
||||||
|
p := &stdio.StreamPage{Last: uint64(len(said))}
|
||||||
|
for _, e := range said {
|
||||||
|
if e.Sequence >= max(from, 1) && e.Event == events && len(p.Entries) < limit {
|
||||||
|
p.Entries = append(p.Entries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := len(p.Entries); n > 0 && p.Entries[n-1].Sequence < p.Last {
|
||||||
|
p.Next = p.Entries[n-1].Sequence + 1
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
pages := 0
|
||||||
|
for from := uint64(0); ; pages++ {
|
||||||
|
page, err := ReadStepLog(read, "r1", "s1", from)
|
||||||
|
if err != nil || page.Gap || page.Bytes > LogPageBytes {
|
||||||
|
t.Fatalf("page from %d: %+v, %v", from, page.Next, err)
|
||||||
|
}
|
||||||
|
got = append(got, page.Lines...)
|
||||||
|
if from = page.Next; from == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Each page opens with the failure it reads: "=== <package> <test>".
|
||||||
|
own := slices.DeleteFunc(slices.Clone(got), func(l string) bool { return l == "=== b t" })
|
||||||
|
if pages < 2 || got[0] != "=== b t" || strings.Join(own, "\n") != strings.Join(lines, "\n") {
|
||||||
|
t.Errorf("%d pages, %d lines read back, want %d in several pages, headed by the failure", pages+1, len(own), len(lines))
|
||||||
|
}
|
||||||
|
if _, err := ReadStepLog(read, "r1", "s1.>", 0); err == nil {
|
||||||
|
t.Error("a step id that is no token was read")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -29,6 +29,16 @@ type Failure struct {
|
|||||||
// Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads
|
// Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads
|
||||||
// only its first line.
|
// only its first line.
|
||||||
Output string `json:"output,omitempty"`
|
Output string `json:"output,omitempty"`
|
||||||
|
// Full is the failing test's whole output, its last FullBytes, passing lines kept: it crosses from a shut-off level
|
||||||
|
// process to the step, which says it on the build module's own log stream and empties it, so it never reaches the
|
||||||
|
// step's result (narrate).
|
||||||
|
Full string `json:"full_output,omitempty"`
|
||||||
|
// Step, Module, Machine and Run say which build step said the whole output on its log, of which build module on which
|
||||||
|
// machine, under which run (delivery.StepLogEvent); set when it was said, absent when it was not.
|
||||||
|
Step string `json:"step,omitempty"`
|
||||||
|
Module string `json:"module,omitempty"`
|
||||||
|
Machine string `json:"machine,omitempty"`
|
||||||
|
Run string `json:"run,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
|
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
|
||||||
@@ -109,6 +119,19 @@ func Clip(lines []string, cause string) string {
|
|||||||
return strings.Join(kept, "\n")
|
return strings.Join(kept, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FullOf joins a failure's whole output, its last FullBytes when longer, starting on a whole line.
|
||||||
|
func FullOf(lines []string) string {
|
||||||
|
s := strings.Join(lines, "\n")
|
||||||
|
if len(s) <= FullBytes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
tail := s[len(s)-FullBytes:]
|
||||||
|
if i := strings.IndexByte(tail, '\n'); i >= 0 {
|
||||||
|
tail = tail[i+1:]
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("... (%d earlier bytes)\n", len(s)-len(tail)) + strings.ToValidUTF8(tail, "")
|
||||||
|
}
|
||||||
|
|
||||||
// CutLine cuts a line to LineBytes, on a character boundary.
|
// CutLine cuts a line to LineBytes, on a character boundary.
|
||||||
func CutLine(l string) string {
|
func CutLine(l string) string {
|
||||||
if len(l) <= LineBytes {
|
if len(l) <= LineBytes {
|
||||||
@@ -143,5 +166,5 @@ func ConsoleFailure(pkg, test, out string) Failure {
|
|||||||
cause = test + " failed and wrote nothing that says why"
|
cause = test + " failed and wrote nothing that says why"
|
||||||
}
|
}
|
||||||
cause = CutLine(cause)
|
cause = CutLine(cause)
|
||||||
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)}
|
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause), Full: FullOf(lines)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ func (r *goReader) subtestTakesTheCause(pkg, test string) bool {
|
|||||||
took := false
|
took := false
|
||||||
for i, f := range r.res.Failed {
|
for i, f := range r.res.Failed {
|
||||||
if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") && f.Cause == noMessage {
|
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)
|
r.res.Failed[i].Cause, r.res.Failed[i].Output, r.res.Failed[i].Full = cause, Clip(r.testOut[key], cause), FullOf(r.testOut[key])
|
||||||
took = true
|
took = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ func (r *goReader) testFailure(pkg, test string) Failure {
|
|||||||
if cause == "" {
|
if cause == "" {
|
||||||
cause = noMessage
|
cause = noMessage
|
||||||
}
|
}
|
||||||
return Failure{Package: pkg, Test: test, Output: Clip(r.testOut[key], cause), Cause: cause}
|
return Failure{Package: pkg, Test: test, Output: Clip(r.testOut[key], cause), Cause: cause, Full: FullOf(r.testOut[key])}
|
||||||
}
|
}
|
||||||
|
|
||||||
// goSummaryLine says a line is go test's summary of a package, in go's exact forms only: "PASS" or "FAIL" alone,
|
// goSummaryLine says a line is go test's summary of a package, in go's exact forms only: "PASS" or "FAIL" alone,
|
||||||
@@ -380,7 +380,7 @@ func (r *goReader) packageFailure(name string) Failure {
|
|||||||
if cause == "" {
|
if cause == "" {
|
||||||
cause = "the package failed with no test to name and no panic, signal or build error in its output"
|
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}
|
return Failure{Package: name, Test: goTestNames(running), Output: Clip(lines, cause), Cause: cause, Full: FullOf(lines)}
|
||||||
}
|
}
|
||||||
|
|
||||||
var goFileAndLine = regexp.MustCompile(`^\S+\.go:\d+: `)
|
var goFileAndLine = regexp.MustCompile(`^\S+\.go:\d+: `)
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ type phases struct {
|
|||||||
ended bool
|
ended bool
|
||||||
logf func(format string, args ...any)
|
logf func(format string, args ...any)
|
||||||
prefix string
|
prefix string
|
||||||
|
// run is the ask's run (delivery.StepAsk.Run), and log the step's log (steplog.go), made at the first line said.
|
||||||
|
run string
|
||||||
|
log *stepLog
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *phases) enter(phase string) {
|
func (r *phases) enter(phase string) {
|
||||||
|
|||||||
@@ -619,7 +619,7 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
ph := &phases{put: p.Put, key: key, step: step, logf: p.Logf, prefix: fmt.Sprintf("the %s ask %s on %s", ask.Kind, ask.ID, p.Node)}
|
ph := &phases{put: p.Put, key: key, step: step, run: ask.Run, logf: p.Logf, prefix: fmt.Sprintf("the %s ask %s on %s", ask.Kind, ask.ID, p.Node)}
|
||||||
result, err := protect(ctx, func(ctx context.Context) (any, error) {
|
result, err := protect(ctx, func(ctx context.Context) (any, error) {
|
||||||
return p.Run(context.WithValue(ctx, phaseKey{}, ph), ask)
|
return p.Run(context.WithValue(ctx, phaseKey{}, ph), ask)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func finish(put Put, key string, step delivery.Step, result any, err error, logf
|
|||||||
if merr != nil {
|
if merr != nil {
|
||||||
step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error()
|
step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error()
|
||||||
} else {
|
} else {
|
||||||
step.Result = raw
|
step.Result = (*stepLog)(nil).narrate(raw, nil) // a step run by hand keeps no whole output in its result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case errors.As(err, &ref):
|
case errors.As(err, &ref):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -86,6 +87,21 @@ func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) {
|
|||||||
result string
|
result string
|
||||||
}{
|
}{
|
||||||
{"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`},
|
{"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`},
|
||||||
|
// A step run by hand says no log: a failure's whole output never reaches its result.
|
||||||
|
{"no whole output", func(context.Context) (any, error) {
|
||||||
|
return map[string]any{"failed": []map[string]string{{"package": "p", "full_output": "all of it"}}}, nil
|
||||||
|
}, delivery.StepPassed, "", `{"failed":[{"package":"p"}]}`},
|
||||||
|
// A log the bus does not take is tried once in a step, then left: the step passes, its failures name no step.
|
||||||
|
{"a dead log", func(context.Context) (any, error) {
|
||||||
|
tries := 0
|
||||||
|
l := &stepLog{say: func(string, any) error { tries++; return errors.New("timeout") }, id: "s1", run: "r1"}
|
||||||
|
raw := l.narrate(json.RawMessage(`{"failed":[{"package":"p","full_output":"a"},{"package":"q","full_output":"b"}]}`), nil)
|
||||||
|
raw = l.narrate(raw, nil)
|
||||||
|
if tries != 1 {
|
||||||
|
return nil, fmt.Errorf("%d publishes, want one", tries)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}, delivery.StepPassed, "", `{"failed":[{"package":"p"},{"package":"q"}]}`},
|
||||||
{"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""},
|
{"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""},
|
||||||
{"refusal", func(context.Context) (any, error) {
|
{"refusal", func(context.Context) (any, error) {
|
||||||
return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"}
|
return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package buildstep
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A build step's log on its build module's own stream (the operator, 2026-10-30: a verdict cut a failure to its last four
|
||||||
|
// kilobytes and named no place that held the rest, so a check's cause was guessed; novox/hq ADR 0157 for the shape: a
|
||||||
|
// build says what it does on the bus, under its id, in sequenced lines, read back from the stream). The step's result
|
||||||
|
// keeps each failure's Output clipped to OutputBytes, the summary; the failure's whole output, up to FullBytes, is said
|
||||||
|
// as delivery.StepLog events on delivery.StepLogEvent(run, step), at most LogEventBytes each, on the stream the module
|
||||||
|
// declares for `log.**` (max age and size in its manifest, dropping its oldest). ReadStepLog reads it back in pages.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FullBytes is the most of one failure's output that is said whole (its last bytes).
|
||||||
|
FullBytes = 1 << 20
|
||||||
|
// LogEventBytes is the most lines one log event holds, and LogPageBytes the most one page of a read answers.
|
||||||
|
LogEventBytes = 64 << 10
|
||||||
|
LogPageBytes = 64 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// stepLog says one step's log: its node, id and run, the build module, how to say an event, and the next line's number.
|
||||||
|
type stepLog struct {
|
||||||
|
say func(event string, body any) error
|
||||||
|
module, node string
|
||||||
|
id, run string
|
||||||
|
seq int
|
||||||
|
// dead says a publish failed: the step says nothing more on its log, so a bus that does not take its lines costs the
|
||||||
|
// step at most one publish's timeout, and never fails it (the 2026-10-30 review).
|
||||||
|
dead bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepLog is the log of the step whose ask runs under ctx, or nil when no ask runs (a tool called by hand) or the module
|
||||||
|
// says no log.
|
||||||
|
func (s AskSteps) stepLog(ctx context.Context) *stepLog {
|
||||||
|
r, ok := ctx.Value(phaseKey{}).(*phases)
|
||||||
|
if !ok || r == nil || s.Narrate == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.log == nil {
|
||||||
|
r.log = &stepLog{say: s.Narrate, module: s.Module, node: r.step.Node, id: r.step.ID, run: r.run}
|
||||||
|
}
|
||||||
|
return r.log
|
||||||
|
}
|
||||||
|
|
||||||
|
// narrate says every failure's Full of a step answer's "failed" on the step's log and empties it, setting the failure's
|
||||||
|
// Step, Module, Machine and Run; a failure whose lines could not all be said keeps no address, that is said once through
|
||||||
|
// logf, and the step says nothing more on its log (dead). With l nil, Full is only emptied: the step's result never holds it.
|
||||||
|
func (l *stepLog) narrate(raw json.RawMessage, logf func(string, ...any)) json.RawMessage {
|
||||||
|
if !strings.Contains(string(raw), `"full_output"`) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
var answer map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &answer); err != nil || answer["failed"] == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
var failed []map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(answer["failed"], &failed); err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
for i, f := range failed {
|
||||||
|
var full string
|
||||||
|
_ = json.Unmarshal(f["full_output"], &full)
|
||||||
|
delete(failed[i], "full_output")
|
||||||
|
if full == "" || l == nil || l.dead {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var name struct {
|
||||||
|
Package string `json:"package"`
|
||||||
|
Test string `json:"test"`
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(f)
|
||||||
|
_ = json.Unmarshal(b, &name)
|
||||||
|
if err := l.say1(name.Package, name.Test, strings.Split(full, "\n")); err != nil {
|
||||||
|
l.dead = true
|
||||||
|
if logf != nil {
|
||||||
|
logf("the step %s says no more on its log, from the whole output of %s %s on: %v", l.id, name.Package, name.Test, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for field, v := range map[string]string{"step": l.id, "module": l.module, "machine": l.node, "run": l.run} {
|
||||||
|
failed[i][field], _ = json.Marshal(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
answer["failed"], _ = json.Marshal(failed)
|
||||||
|
out, err := json.Marshal(answer)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// say1 says one failure's lines, in events of at most LogEventBytes, numbered on from the step's last line.
|
||||||
|
func (l *stepLog) say1(pkg, test string, lines []string) error {
|
||||||
|
event := delivery.StepLogEvent(l.run, l.id)
|
||||||
|
for len(lines) > 0 {
|
||||||
|
n, size := 0, 0
|
||||||
|
for n < len(lines) && (n == 0 || size+len(lines[n])+1 <= LogEventBytes) {
|
||||||
|
if len(lines[n]) > LogEventBytes {
|
||||||
|
lines[n] = strings.ToValidUTF8(lines[n][:LogEventBytes], "") // one line longer than an event is cut
|
||||||
|
}
|
||||||
|
size += len(lines[n]) + 1
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if err := l.say(event, delivery.StepLog{Seq: l.seq + 1, Package: pkg, Test: test, Lines: lines[:n]}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
l.seq += n
|
||||||
|
lines = lines[n:]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamRead reads a module's own stream: stdio.Stream(name).Read's shape.
|
||||||
|
type StreamRead func(events string, from uint64, limit int) (*stdio.StreamPage, error)
|
||||||
|
|
||||||
|
// StepLogPage is one page of a step's log: the lines from the stream sequence From on, headed "=== <package> <test>"
|
||||||
|
// where a failure's lines start on the page, Next the sequence to read on from (0 when nothing follows yet; a step still running may
|
||||||
|
// say more), Last the stream's last sequence, and Gap when a line number was skipped (the stream dropped it with age).
|
||||||
|
type StepLogPage struct {
|
||||||
|
Run string `json:"run"`
|
||||||
|
Step string `json:"step"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
From uint64 `json:"from"`
|
||||||
|
Lines []string `json:"lines"`
|
||||||
|
Bytes int `json:"bytes"`
|
||||||
|
Next uint64 `json:"next"`
|
||||||
|
Last uint64 `json:"last"`
|
||||||
|
Gap bool `json:"gap,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadStepLog reads one page of about LogPageBytes of the log of the step id under run, from the stream sequence from on.
|
||||||
|
func ReadStepLog(read StreamRead, run, id string, from uint64) (StepLogPage, error) {
|
||||||
|
if !stepToken(run) || !stepToken(id) {
|
||||||
|
return StepLogPage{}, fmt.Errorf("a step's log is named by its run and its step id, each one token of letters, digits and -: %q, %q", run, id)
|
||||||
|
}
|
||||||
|
event := delivery.StepLogEvent(run, id)
|
||||||
|
page := StepLogPage{Run: run, Step: id, Event: event, From: from, Lines: []string{}}
|
||||||
|
seq, test := 0, "\x00"
|
||||||
|
for {
|
||||||
|
got, err := read(event, from, 16)
|
||||||
|
if err != nil {
|
||||||
|
return page, fmt.Errorf("the log %s cannot be read: %w", event, err)
|
||||||
|
}
|
||||||
|
page.Last = got.Last
|
||||||
|
for _, e := range got.Entries {
|
||||||
|
var l delivery.StepLog
|
||||||
|
if err := json.Unmarshal(e.Body, &l); err != nil {
|
||||||
|
return page, fmt.Errorf("the log %s holds at %d what is no log event: %v", event, e.Sequence, err)
|
||||||
|
}
|
||||||
|
size := 0
|
||||||
|
for _, line := range l.Lines {
|
||||||
|
size += len(line) + 1
|
||||||
|
}
|
||||||
|
if page.Bytes > 0 && page.Bytes+size > LogPageBytes {
|
||||||
|
page.Next = e.Sequence
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
if seq > 0 && l.Seq != seq {
|
||||||
|
page.Gap = true
|
||||||
|
}
|
||||||
|
if name := l.Package + " " + l.Test; name != test {
|
||||||
|
page.Lines = append(page.Lines, "=== "+strings.TrimSpace(name))
|
||||||
|
test = name
|
||||||
|
}
|
||||||
|
page.Lines = append(page.Lines, l.Lines...)
|
||||||
|
page.Bytes += size
|
||||||
|
seq = l.Seq + len(l.Lines)
|
||||||
|
}
|
||||||
|
if got.Next == 0 {
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
from = got.Next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepToken is one subject token of a run or a step id.
|
||||||
|
func stepToken(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogStreamOf is the local name of a build module's own log stream, <language>-build-logs (a stream's bus name is
|
||||||
|
// mesh-wide): LogStreamOf names it.
|
||||||
|
func LogStreamOf(language string) string { return language + "-build-logs" }
|
||||||
|
|
||||||
|
// LogTool is a build module's tool that reads one step's log from its own stream, {run, step, from}: a failed test's
|
||||||
|
// whole output, which the step's result and the checker's verdict keep cut. The stream is the mesh's, so any machine's
|
||||||
|
// instance answers for a step of any machine.
|
||||||
|
func LogTool(name string, read StreamRead) stdio.Tool {
|
||||||
|
return stdio.Tool{Name: name,
|
||||||
|
Description: "The full build log of one build step: the whole output of every test that failed in it, which a check's " +
|
||||||
|
"verdict and the step's result keep cut to their last lines (\"... (N earlier lines)\"). One page of about 64 KB; " +
|
||||||
|
"read on with from = the answer's next until it is 0 (a step still running may say more: read again from next). " +
|
||||||
|
"run is the check (its heads hash) or the delivery plan the step ran for, \"none\" when the ask named none; step " +
|
||||||
|
"is the step id; both are in a failed verdict (checker_failure, checker_pull). Kept 14 days. Read-only.",
|
||||||
|
Input: map[string]any{"type": "object", "required": []string{"run", "step"}, "properties": map[string]any{
|
||||||
|
"run": map[string]any{"type": "string", "description": "the check or delivery plan the step ran for, or none"},
|
||||||
|
"step": map[string]any{"type": "string", "description": "the step id (the ask's id)"},
|
||||||
|
"from": map[string]any{"type": "integer", "description": "the stream sequence to read on from: the last answer's next; 0 is the start"}}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
run, _ := args["run"].(string)
|
||||||
|
step, _ := args["step"].(string)
|
||||||
|
from, _ := args["from"].(float64)
|
||||||
|
if from < 0 {
|
||||||
|
return nil, fmt.Errorf("from is a stream sequence, 0 or more")
|
||||||
|
}
|
||||||
|
return ReadStepLog(read, run, step, uint64(from))
|
||||||
|
}}
|
||||||
|
}
|
||||||
+1
-1
@@ -107,7 +107,7 @@ func ReadTAP(pkg, script, dir, report, console string) []Failure {
|
|||||||
if dir != "" {
|
if dir != "" {
|
||||||
file = strings.TrimPrefix(file, strings.TrimSuffix(dir, "/")+"/")
|
file = strings.TrimPrefix(file, strings.TrimSuffix(dir, "/")+"/")
|
||||||
}
|
}
|
||||||
f := Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause)}
|
f := Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause), Full: FullOf(block)}
|
||||||
if fields["failureType"] == "cancelledByParent" {
|
if fields["failureType"] == "cancelledByParent" {
|
||||||
cancelled = append(cancelled, f)
|
cancelled = append(cancelled, f)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package buscompose
|
package buscompose
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
|
|||||||
keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true})
|
keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true})
|
||||||
queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32,
|
queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32,
|
||||||
WorkQueue: true, Duplicates: 1})
|
WorkQueue: true, Duplicates: 1})
|
||||||
|
logs := ModuleStream{Module: "mesh-build-go", Name: "GO_BUILD_LOGS", Events: []string{"log.>"}, MaxAgeDays: 14, MaxMiB: 2048}
|
||||||
|
lcfg := ModuleStreamConfig(logs)
|
||||||
|
grants := strings.Join(ownStreamGrants("mesh-build-go", []ModuleStream{logs, {Module: "b", Name: "ASKS", Events: []string{"ask"}, WorkQueue: true}}), " ")
|
||||||
for _, row := range []struct {
|
for _, row := range []struct {
|
||||||
what string
|
what string
|
||||||
got, want any
|
got, want any
|
||||||
@@ -37,6 +41,13 @@ func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
|
|||||||
{"queue cap", queue.MaxBytes, int64(32 << 20)},
|
{"queue cap", queue.MaxBytes, int64(32 << 20)},
|
||||||
{"queue discards new", queue.Discard, jetstream.DiscardNew},
|
{"queue discards new", queue.Discard, jetstream.DiscardNew},
|
||||||
{"queue duplicates", queue.Duplicates, time.Hour},
|
{"queue duplicates", queue.Duplicates, time.Hour},
|
||||||
|
{"queue is read by its consumer, never directly", queue.AllowDirect, false},
|
||||||
|
{"a module's stream is read back directly", lcfg.AllowDirect, true},
|
||||||
|
{"a module's stream drops its oldest", lcfg.Discard, jetstream.DiscardOld},
|
||||||
|
{"a module's stream cap", lcfg.MaxBytes, int64(2048 << 20)},
|
||||||
|
{"a module's stream age", lcfg.MaxAge, 14 * 24 * time.Hour},
|
||||||
|
{"its owner publishes and reads it, and no other's", grants,
|
||||||
|
"mesh.stream.mesh-build-go.event.log.> $JS.API.STREAM.INFO.GO_BUILD_LOGS $JS.API.DIRECT.GET.GO_BUILD_LOGS"},
|
||||||
{"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"},
|
{"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"},
|
||||||
} {
|
} {
|
||||||
if row.got != row.want {
|
if row.got != row.want {
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ func (s ModuleStream) Stream() Stream {
|
|||||||
Why: why,
|
Why: why,
|
||||||
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
|
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
|
||||||
Restricted: s.Restricted,
|
Restricted: s.Restricted,
|
||||||
|
// Its owner reads it back by direct gets, with no consumer (its runtime's stream.read, as a log's read).
|
||||||
|
Direct: !s.WorkQueue,
|
||||||
}
|
}
|
||||||
if s.WorkQueue {
|
if s.WorkQueue {
|
||||||
out.Retention = RetentionWorkQueue
|
out.Retention = RetentionWorkQueue
|
||||||
@@ -248,7 +250,9 @@ func heardGrants(p Principal, hears []Heard) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
|
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's, and the reading of
|
||||||
|
// each back by direct gets (its runtime's stream.read: the stream's info and the batch direct get, with no consumer), as
|
||||||
|
// a log is read. A work-queue stream is its one consumer's to read, never by direct get.
|
||||||
func ownStreamGrants(module string, streams []ModuleStream) []string {
|
func ownStreamGrants(module string, streams []ModuleStream) []string {
|
||||||
var out []string
|
var out []string
|
||||||
for _, s := range streams {
|
for _, s := range streams {
|
||||||
@@ -256,6 +260,9 @@ func ownStreamGrants(module string, streams []ModuleStream) []string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, s.Subjects()...)
|
out = append(out, s.Subjects()...)
|
||||||
|
if !s.WorkQueue && safeSubject.MatchString(s.Name) {
|
||||||
|
out = append(out, "$JS.API.STREAM.INFO."+s.Name, "$JS.API.DIRECT.GET."+s.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
// **The artifact sweep, out of the controller** (novox/hq ADR 0571 clause 2, issue 1955): what the artifact store keeps
|
||||||
|
// and lets go of, the seat `mesh-artifact-sweep` (named for its scope, ADR 0121 §1; the operator, 2026-10-29), held
|
||||||
|
// once for the mesh by artifact-store-tools, serves what the controller served of its store (artifacts, held, mirrors, images, collect) and says `artifacts-gone`. Its keep set is the store's own listing joined
|
||||||
|
// with every want's fingerprint and each module's five newest builds from the builder's log (ADR 0526 §1); it lets go
|
||||||
|
// of nothing until the controller's lease names a holder that is no controller.
|
||||||
|
|
||||||
|
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
|
||||||
|
// distribution, and not the provision artifact-store it delivers.
|
||||||
|
const ArtifactSweepSeat = "mesh-artifact-sweep"
|
||||||
|
|
||||||
|
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
|
||||||
|
func artifactSweepVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
|
||||||
|
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
|
||||||
|
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
|
||||||
|
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
||||||
|
"collected": "\"true\": list the collected references too (counted always)",
|
||||||
|
}, nil, "collected")},
|
||||||
|
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
|
||||||
|
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
|
||||||
|
"Reads only.",
|
||||||
|
Input: listed(schema(map[string]string{
|
||||||
|
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
|
||||||
|
"or one text separated by commas",
|
||||||
|
}, []string{"references"}), "references")},
|
||||||
|
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
|
||||||
|
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
|
||||||
|
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
|
||||||
|
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
|
||||||
|
"seconds; what is left is said. Never touches a digest the mesh did not make.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
|
||||||
|
"why": "with confirm: why, required and recorded",
|
||||||
|
"most": "let go of at most this many (500 by default, at most 5000)",
|
||||||
|
}, nil, "confirm")},
|
||||||
|
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
|
||||||
|
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
|
||||||
|
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
|
||||||
|
"naming it: what the machine keeps when it prunes its images. Reads only.",
|
||||||
|
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-29
@@ -126,7 +126,23 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made := by[a.Name]
|
made := by[a.Name]
|
||||||
loads := BundleLoads(m, a, run)
|
// 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}
|
||||||
|
}
|
||||||
|
}
|
||||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
// **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 —
|
// 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
|
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
||||||
@@ -699,31 +715,3 @@ func undeliveredBundles(m Manifest) []string {
|
|||||||
}
|
}
|
||||||
return problems
|
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,64 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
// **The control records, out of the controller** (novox/hq ADR 0565 clause 1, issue 1953): two seats at mesh scope,
|
||||||
|
// each held once, and the node seat's verb `packages`.
|
||||||
|
//
|
||||||
|
// - mesh-wants, held by the assigner (mesh-delivery-assigner): a person's upgrade policy and the bus step (clauses 7,
|
||||||
|
// 8, 9);
|
||||||
|
// - mesh-hand-acts, held by the module mesh-hand-acts: the hand-act log and the warrants recorded (clause 10).
|
||||||
|
//
|
||||||
|
// Their events are their holders' to say on the seat; their layouts are go/handacts' and go/delivery's.
|
||||||
|
|
||||||
|
// The two seats.
|
||||||
|
const (
|
||||||
|
WantsSeat = "mesh-wants"
|
||||||
|
HandActsSeat = "mesh-hand-acts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// controlRecordsDecision is the record that made the two seats.
|
||||||
|
const controlRecordsDecision = "novox/hq ADR 0565"
|
||||||
|
|
||||||
|
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
|
||||||
|
func wantsVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
|
||||||
|
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
|
||||||
|
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
|
||||||
|
"with policy default, the choice taken back. With compare, every module whose policy differs from the one the " +
|
||||||
|
"controller writes beside its compositions.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"module": "one module",
|
||||||
|
"policy": "with module: roll, together, record, or default to take a person's choice back",
|
||||||
|
"why": "with policy: why, required and kept",
|
||||||
|
"compare": "\"true\": every module whose policy at the assigner differs from the controller's",
|
||||||
|
}, nil, "compare")},
|
||||||
|
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
|
||||||
|
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
|
||||||
|
"the step itself is the put on the operator's warrant.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
|
||||||
|
func handActsVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
|
||||||
|
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
|
||||||
|
"runtime proves, never said by the caller. Said as act-recorded.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"what": "what was done, in a line",
|
||||||
|
"why": "why it had to be done by hand",
|
||||||
|
"cause": "the cause in a word, or a condition's kind",
|
||||||
|
"condition": "the key of the condition it addressed, if any (optional)",
|
||||||
|
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
|
||||||
|
}, []string{"what", "why"})},
|
||||||
|
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
|
||||||
|
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
|
||||||
|
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
|
||||||
|
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
|
||||||
|
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
|
||||||
|
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
|
||||||
|
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
|
||||||
|
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -45,6 +46,31 @@ func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) {
|
|||||||
if it, _ := m.DataItem("dumps"); it.BackedUp() {
|
if it, _ := m.DataItem("dumps"); it.BackedUp() {
|
||||||
t.Fatal("a rebuildable item that says none is backed up")
|
t.Fatal("a rebuildable item that says none is backed up")
|
||||||
}
|
}
|
||||||
|
// Issue 774's new words first: an offer's credential form and an artifact's bus are accepted and change nothing: the
|
||||||
|
// manifest that says them composes byte-equal to the one that does not, and no form still means a pair.
|
||||||
|
plain := strings.Replace(declaredStore, `"resources":[`, `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/x","binary":"x","loads":["x"]}]},"resources":[`, 1)
|
||||||
|
for _, form := range []string{"pair", "none"} {
|
||||||
|
worded := strings.Replace(plain, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"`+form+`"}}]`, 1)
|
||||||
|
worded = strings.Replace(worded, `"loads":["x"]}`, `"loads":["x"],"bus":{"state":["acts"],"removes":["engine-acts"]}}`, 1)
|
||||||
|
a, b := mustParse(t, plain), mustParse(t, worded)
|
||||||
|
if b.Provides[0].Credential != nil || b.CredentialFormOf("postgres-database") != a.CredentialFormOf("postgres-database") ||
|
||||||
|
a.CredentialFormOf("postgres-database") != CredentialFormPair || len(b.Build.Artifacts[0].Bus) == 0 {
|
||||||
|
t.Fatalf("form %s: offer %+v, form %q against %q", form, b.Provides[0], b.CredentialFormOf("postgres-database"),
|
||||||
|
a.CredentialFormOf("postgres-database"))
|
||||||
|
}
|
||||||
|
b.Build.Artifacts[0].Bus = nil
|
||||||
|
ra, erra := (Resolution{Node: "anchor", Modules: []Manifest{a}}).Compose(Rendering{})
|
||||||
|
rb, errb := (Resolution{Node: "anchor", Modules: []Manifest{b}}).Compose(Rendering{})
|
||||||
|
ja, _ := json.Marshal(ra.Resources)
|
||||||
|
jb, _ := json.Marshal(rb.Resources)
|
||||||
|
if erra != nil || errb != nil || string(ja) != string(jb) || len(ra.Resources) == 0 {
|
||||||
|
t.Fatalf("form %s composes %s against %s (%v, %v)", form, jb, ja, errb, erra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ParseManifest([]byte(strings.Replace(declaredStore, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"shared"}}]`, 1))); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), `the form "shared", not "pair" or "none"`) {
|
||||||
|
t.Fatalf("a form the release does not know: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every rule of the section itself, refused at parse in the words of the module.
|
// Every rule of the section itself, refused at parse in the words of the module.
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ func EngineVerbs() []Verb {
|
|||||||
"each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " +
|
"each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " +
|
||||||
"operator's warrant. Read-only.",
|
"operator's warrant. Read-only.",
|
||||||
Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)},
|
Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)},
|
||||||
|
// The packages this node's compositions declare (novox/hq ADR 0565 §6, §15): optional until every engine serves it.
|
||||||
|
{Name: "packages", Optional: true, Description: "The packages this machine's compositions declare, present or absent, " +
|
||||||
|
"each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller " +
|
||||||
|
"hands its lease away, beside the controller's mirror for this machine and their difference. With write, the " +
|
||||||
|
"key written again from what the engine composed.",
|
||||||
|
Input: schema(map[string]string{"write": "\"true\": write this machine's key again from what the engine composed"}, nil, "write")},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
// **The mesh's health, out of the controller** (novox/hq ADR 0564 clause 1, issue 1952): four seats at mesh scope, each
|
||||||
|
// held once, each holder a process on its own bus credential that writes only its own state.
|
||||||
|
//
|
||||||
|
// - mesh-conditions, held by the module mesh-conditions (the condition keeper): the one writer of the open conditions,
|
||||||
|
// raised from the observers' findings and cleared when they go (clauses 2, 3, 9, 13);
|
||||||
|
// - mesh-watchdogs, held by the watchman (mesh-delivery-watchman): the signals as keys that expire, its stuck keys and
|
||||||
|
// presence as findings, and the dead letters from the lease write (clauses 4, 5);
|
||||||
|
// - mesh-self-check, held by the module mesh-self-check: the probes, every five minutes (clause 6);
|
||||||
|
// - mesh-healers, held by the module mesh-healers: the healers, acting on the keeper's events (clauses 7, 8).
|
||||||
|
//
|
||||||
|
// Their events keep the controller's names and bodies (go/health), so a reader changes only the seat it hears.
|
||||||
|
|
||||||
|
// The four health seats.
|
||||||
|
const (
|
||||||
|
ConditionsSeat = "mesh-conditions"
|
||||||
|
WatchdogsSeat = "mesh-watchdogs"
|
||||||
|
SelfCheckSeat = "mesh-self-check"
|
||||||
|
HealersSeat = "mesh-healers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// healthDecision is the record that made the four seats.
|
||||||
|
const healthDecision = "novox/hq ADR 0564"
|
||||||
|
|
||||||
|
// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12).
|
||||||
|
func conditionsVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
|
||||||
|
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
|
||||||
|
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
|
||||||
|
"lately, the controller's before the take-over included.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"key": "a condition's key: that one whole; with history, only its transitions",
|
||||||
|
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
|
||||||
|
"severity": "only urgent, or only warning",
|
||||||
|
"machine": "only those about this machine",
|
||||||
|
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
|
||||||
|
"days": "with history: how many days back (default 7, at most 90)",
|
||||||
|
}, nil, "history")},
|
||||||
|
{Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " +
|
||||||
|
"status, and the silence is kept as its transition in history.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"key": "the condition's key",
|
||||||
|
"for": "how long: 30m, 4h, 2d; at most 7d",
|
||||||
|
"why": "why, required and recorded",
|
||||||
|
"cause": "the cause in a word (the condition's kind when absent)",
|
||||||
|
}, []string{"key", "for", "why"})},
|
||||||
|
{Name: "compare", Description: "The keeper's open conditions against the controller's: every key open in one and not " +
|
||||||
|
"the other, or open in both with another severity, each with its reason; the kinds that end with the controller " +
|
||||||
|
"and the keys held while their module is delivered listed apart and not counted. The take-over waits for it empty.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "take-over", Description: "Ask the operator's Approve to switch who the router, the grants part and " +
|
||||||
|
"mesh-watcher hear from the controller to the keeper; refused while compare shows a difference. On the warrant " +
|
||||||
|
"the keeper writes its take-over key.",
|
||||||
|
Input: schema(map[string]string{"why": "why now, shown to the operator"}, []string{"why"})},
|
||||||
|
{Name: "hand-back", Description: "Switch the readers back to the controller's conditions, until the controller's " +
|
||||||
|
"lease is handed away; refused after it.",
|
||||||
|
Input: schema(map[string]string{"why": "why, kept with the switch"}, []string{"why"})},
|
||||||
|
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
|
||||||
|
"conditions: the recovery of a keeper that missed a change.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12).
|
||||||
|
func watchdogsVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " +
|
||||||
|
"its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " +
|
||||||
|
"expiry wrote.",
|
||||||
|
Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)},
|
||||||
|
{Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " +
|
||||||
|
"when and the words of its finding.",
|
||||||
|
Input: schema(map[string]string{"machine": "only those on this machine"}, nil)},
|
||||||
|
{Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " +
|
||||||
|
"those it says silent; a machine that said it sleeps is shown as sleeping, not silent.",
|
||||||
|
Input: schema(map[string]string{"machine": "one machine"}, nil)},
|
||||||
|
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
|
||||||
|
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
|
||||||
|
"whole. With deliver or drop, a hand act with why, taken only once the controller's lease is handed away; " +
|
||||||
|
"before, refused naming the controller's verb.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
|
||||||
|
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
|
||||||
|
"limit": "how many to list, newest first (default 50); only when listing",
|
||||||
|
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
|
||||||
|
"drop": "a dead letter's id: drop it for good (needs why)",
|
||||||
|
"why": "with deliver or drop: why, required and recorded",
|
||||||
|
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
|
||||||
|
}, nil)},
|
||||||
|
{Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " +
|
||||||
|
"the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6).
|
||||||
|
func selfCheckVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " +
|
||||||
|
"(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " +
|
||||||
|
"probe's verdict whole. Runs every five minutes on its own; each failure is a finding.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"run": "\"true\": run every probe now and answer the verdict",
|
||||||
|
"probe": "one probe's id (D6): its verdict alone, with every finding",
|
||||||
|
}, nil, "run")},
|
||||||
|
{Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8).
|
||||||
|
func healersVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
|
||||||
|
"budget and what happens when it is spent; every act taken lately with its outcome, the acts it would have " +
|
||||||
|
"taken while the controller's lease names a controller, and the mesh-wide brake.",
|
||||||
|
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
|
||||||
|
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"key": "the condition's key",
|
||||||
|
"why": "why now, kept with the act",
|
||||||
|
}, []string{"key", "why"})},
|
||||||
|
{Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " +
|
||||||
|
"what it tests. A drill is never a repair: no healer is wanted for it however often it is run.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"what": "what was done on purpose, in a line",
|
||||||
|
"why": "what the drill tests",
|
||||||
|
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
|
||||||
|
}, []string{"what", "why"})},
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-5
@@ -27,8 +27,15 @@ type LogDeclaration struct {
|
|||||||
// MaxMiB is the log's cap in MiB; zero is LogDefaultMiB. When full, the log refuses new entries
|
// MaxMiB is the log's cap in MiB; zero is LogDefaultMiB. When full, the log refuses new entries
|
||||||
// and never drops old ones.
|
// and never drops old ones.
|
||||||
MaxMiB int `json:"max-mib,omitempty"`
|
MaxMiB int `json:"max-mib,omitempty"`
|
||||||
|
// MaxAgeDays is how long an entry is kept, in days; zero is as long as the log (novox/hq ADR 0297). The one way an
|
||||||
|
// entry leaves a log: the server ages it out, never a delete or a purge (novox/hq ADR 0565 §10: the hand-act log
|
||||||
|
// keeps every act 400 days; issue 1953 S-age).
|
||||||
|
MaxAgeDays int `json:"max-age-days,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogMostAgeDays is the longest a log may keep an entry by age: past it, a log keeps its entries as long as it is.
|
||||||
|
const LogMostAgeDays = 3650
|
||||||
|
|
||||||
// Cap is the log's cap in MiB, the default where none is said.
|
// Cap is the log's cap in MiB, the default where none is said.
|
||||||
func (l LogDeclaration) Cap() int {
|
func (l LogDeclaration) Cap() int {
|
||||||
if l.MaxMiB == 0 {
|
if l.MaxMiB == 0 {
|
||||||
@@ -37,20 +44,21 @@ func (l LogDeclaration) Cap() int {
|
|||||||
return l.MaxMiB
|
return l.MaxMiB
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON reads a log as its bare name, or as {name, max-mib}.
|
// UnmarshalJSON reads a log as its bare name, or as {name, max-mib, max-age-days}.
|
||||||
func (l *LogDeclaration) UnmarshalJSON(raw []byte) error {
|
func (l *LogDeclaration) UnmarshalJSON(raw []byte) error {
|
||||||
trimmed := bytes.TrimSpace(raw)
|
trimmed := bytes.TrimSpace(raw)
|
||||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
if len(trimmed) > 0 && trimmed[0] == '"' {
|
||||||
return json.Unmarshal(trimmed, &l.Name)
|
return json.Unmarshal(trimmed, &l.Name)
|
||||||
}
|
}
|
||||||
var full struct {
|
var full struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MaxMiB *int `json:"max-mib"`
|
MaxMiB *int `json:"max-mib"`
|
||||||
|
MaxAgeDays *int `json:"max-age-days"`
|
||||||
}
|
}
|
||||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&full); err != nil {
|
if err := dec.Decode(&full); err != nil {
|
||||||
return fmt.Errorf("a log is either a name or {name, max-mib}: %w", typedUnknown(err))
|
return fmt.Errorf("a log is either a name or {name, max-mib, max-age-days}: %w", typedUnknown(err))
|
||||||
}
|
}
|
||||||
l.Name = full.Name
|
l.Name = full.Name
|
||||||
l.MaxMiB = 0
|
l.MaxMiB = 0
|
||||||
@@ -61,12 +69,19 @@ func (l *LogDeclaration) UnmarshalJSON(raw []byte) error {
|
|||||||
}
|
}
|
||||||
l.MaxMiB = *full.MaxMiB
|
l.MaxMiB = *full.MaxMiB
|
||||||
}
|
}
|
||||||
|
l.MaxAgeDays = 0
|
||||||
|
if full.MaxAgeDays != nil {
|
||||||
|
if *full.MaxAgeDays < 1 || *full.MaxAgeDays > LogMostAgeDays {
|
||||||
|
return fmt.Errorf("log %q: max-age-days is between 1 and %d, not %d", full.Name, LogMostAgeDays, *full.MaxAgeDays)
|
||||||
|
}
|
||||||
|
l.MaxAgeDays = *full.MaxAgeDays
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON writes back the short form when there is nothing else to say.
|
// MarshalJSON writes back the short form when there is nothing else to say.
|
||||||
func (l LogDeclaration) MarshalJSON() ([]byte, error) {
|
func (l LogDeclaration) MarshalJSON() ([]byte, error) {
|
||||||
if l.MaxMiB == 0 {
|
if l.MaxMiB == 0 && l.MaxAgeDays == 0 {
|
||||||
return json.Marshal(l.Name)
|
return json.Marshal(l.Name)
|
||||||
}
|
}
|
||||||
type plain LogDeclaration
|
type plain LogDeclaration
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) {
|
|||||||
if string(out) != `["changes",{"name":"moves","max-mib":2048}]` {
|
if string(out) != `["changes",{"name":"moves","max-mib":2048}]` {
|
||||||
t.Fatalf("written back as %s", out)
|
t.Fatalf("written back as %s", out)
|
||||||
}
|
}
|
||||||
|
// Issue 774's new words first: max-age-days is accepted and written back; nothing reads it yet.
|
||||||
|
aged, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
|
||||||
|
if out, _ := json.Marshal(aged.Logs); err != nil || aged.Logs[0].MaxAgeDays != 400 || string(out) != `[{"name":"acts","max-age-days":400}]` {
|
||||||
|
t.Fatalf("max-age-days read as %+v, written %s: %v", aged.Logs, out, err)
|
||||||
|
}
|
||||||
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} {
|
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} {
|
||||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil {
|
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil {
|
||||||
t.Errorf("%s is inside the caps and was refused: %v", edge, err)
|
t.Errorf("%s is inside the caps and was refused: %v", edge, err)
|
||||||
@@ -42,7 +47,8 @@ func TestALogIsNamedLocallyAndCapped(t *testing.T) {
|
|||||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`},
|
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`},
|
||||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`},
|
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`},
|
||||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`},
|
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`},
|
||||||
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib}`},
|
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`},
|
||||||
|
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650`},
|
||||||
{`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`},
|
{`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`},
|
||||||
} {
|
} {
|
||||||
_, err := ParseManifest([]byte(c.manifest))
|
_, err := ParseManifest([]byte(c.manifest))
|
||||||
|
|||||||
@@ -287,8 +287,18 @@ type OfferCredential struct {
|
|||||||
Own string `json:"own,omitempty"`
|
Own string `json:"own,omitempty"`
|
||||||
IssuedBy string `json:"issued-by,omitempty"`
|
IssuedBy string `json:"issued-by,omitempty"`
|
||||||
Tool string `json:"tool,omitempty"`
|
Tool string `json:"tool,omitempty"`
|
||||||
|
// Form is the release's word for an offer that names no own secret and no issuer (novox/hq ADR 0570; issue 774's
|
||||||
|
// new words first): CredentialFormPair or CredentialFormNone. Accepted and read by nothing yet: an offer that says
|
||||||
|
// only its form is parsed as one that says no credential, which is a pair, as before.
|
||||||
|
Form string `json:"form,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The forms an offer's credential may say by itself (OfferCredential.Form).
|
||||||
|
const (
|
||||||
|
CredentialFormWordPair = "pair"
|
||||||
|
CredentialFormWordNone = "none"
|
||||||
|
)
|
||||||
|
|
||||||
// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305).
|
// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305).
|
||||||
const IssuedBySoftware = "software"
|
const IssuedBySoftware = "software"
|
||||||
|
|
||||||
@@ -1149,6 +1159,10 @@ type Artifact struct {
|
|||||||
// success, and was invisible to the launcher (novox/hq 04-ISSUES/142).
|
// success, and was invisible to the launcher (novox/hq 04-ISSUES/142).
|
||||||
Binary string `json:"binary,omitempty"`
|
Binary string `json:"binary,omitempty"`
|
||||||
|
|
||||||
|
// Bus is the release's word for a tools bundle's own bus account (novox/hq ADR 0564 Limits 4; issue 774's new words
|
||||||
|
// first): accepted as it is written and read by nothing yet.
|
||||||
|
Bus json.RawMessage `json:"bus,omitempty"`
|
||||||
|
|
||||||
// URL is where an archive's upstream file is fetched from, for an archive that is a release
|
// URL is where an archive's upstream file is fetched from, for an archive that is a release
|
||||||
// somebody else published rather than a directory of this repository (novox/hq ADR 0316 §2, §6).
|
// somebody else published rather than a directory of this repository (novox/hq ADR 0316 §2, §6).
|
||||||
// With it come SHA256 and Version, and From stays empty.
|
// With it come SHA256 and Version, and From stays empty.
|
||||||
@@ -1926,7 +1940,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
|||||||
"%s says its instances are %q; the one word is %q, for a module that is the same on every machine",
|
"%s says its instances are %q; the one word is %q, for a module that is the same on every machine",
|
||||||
m.Module, m.Instances, InstancesInterchangeable))
|
m.Module, m.Instances, InstancesInterchangeable))
|
||||||
}
|
}
|
||||||
for _, offer := range m.Provides {
|
for i, offer := range m.Provides {
|
||||||
p := offer.Name
|
p := offer.Name
|
||||||
if !name.MatchString(p) {
|
if !name.MatchString(p) {
|
||||||
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
||||||
@@ -1979,6 +1993,15 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
|||||||
} else if offer.Credential != nil {
|
} else if offer.Credential != nil {
|
||||||
own, declared := m.OwnSecrets[offer.Credential.Own]
|
own, declared := m.OwnSecrets[offer.Credential.Own]
|
||||||
switch {
|
switch {
|
||||||
|
case offer.Credential.Own == "" && offer.Credential.IssuedBy == "" && offer.Credential.Tool == "" &&
|
||||||
|
offer.Credential.Form != "":
|
||||||
|
// The form alone (issue 774's new words first): pair or none, accepted and meaning what no credential
|
||||||
|
// meant before, a pair (the offer is read as saying none).
|
||||||
|
if f := offer.Credential.Form; f != CredentialFormWordPair && f != CredentialFormWordNone {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, p, f,
|
||||||
|
CredentialFormWordPair, CredentialFormWordNone))
|
||||||
|
}
|
||||||
|
m.Provides[i].Credential = nil
|
||||||
case offer.Credential.Own == "":
|
case offer.Credential.Own == "":
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s provides %q with a credential that names no own secret", m.Module, p))
|
"%s provides %q with a credential that names no own secret", m.Module, p))
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ func placementVerbs() []Verb {
|
|||||||
"history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " +
|
"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.",
|
"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",
|
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")},
|
"history": "\"true\": the changes in order, from the stream and the state's revisions",
|
||||||
|
// ADR 0571 clause 4: who holds which seat on which node, from the state seat-holders.
|
||||||
|
"seats": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's"},
|
||||||
|
nil, "history", "seats")},
|
||||||
{Name: PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " +
|
{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 " +
|
"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.",
|
"answered, its verdict and its ask. Narrowed to one by id.",
|
||||||
|
|||||||
+27
-1
@@ -245,13 +245,25 @@ var defaultSeats = append([]Seat{
|
|||||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
// 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).
|
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||||||
// Optional until the mesh-config that runs serves it (design 33 §7).
|
// Optional until the mesh-config that runs serves it (design 33 §7).
|
||||||
{Name: "mesh-config", Scope: ScopeMesh, Decision: "novox/hq ADR 0348",
|
{Name: "mesh-config", Scope: ScopeMesh, Decision: "novox/hq ADR 0348, ADR 0565",
|
||||||
Serves: []Verb{
|
Serves: []Verb{
|
||||||
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
|
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
|
||||||
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
|
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
|
||||||
"with why, validated, and naming each module on each node whose value moves. A trusted value only " +
|
"with why, validated, and naming each module on each node whose value moves. A trusted value only " +
|
||||||
"from the controller's terminal.",
|
"from the controller's terminal.",
|
||||||
Input: configInput()},
|
Input: configInput()},
|
||||||
|
// The switch of config to its own sources (novox/hq ADR 0565 §4): optional until every mesh-config serves it.
|
||||||
|
{Name: "compare", Optional: true, Description: "Every difference between config's own sources and the " +
|
||||||
|
"controller's records, each with its reason: a module's declared config against the controller's mirror, " +
|
||||||
|
"the indexer's predefined list against the mirror's, and each open pull request's config candidate against " +
|
||||||
|
"what the controller told. The take-over waits for none.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "take-over", Optional: true, Description: "Ask the operator's Approve to switch config to its own " +
|
||||||
|
"sources (the indexer); refused while compare shows a difference.",
|
||||||
|
Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})},
|
||||||
|
{Name: "hand-back", Optional: true, Description: "Switch config back to the controller's mirrors, until the " +
|
||||||
|
"controller hands its lease away; refused after it.",
|
||||||
|
Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})},
|
||||||
}},
|
}},
|
||||||
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
|
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
|
||||||
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
|
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
|
||||||
@@ -280,6 +292,20 @@ var defaultSeats = append([]Seat{
|
|||||||
{Name: PlacementSeat, Scope: ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"},
|
{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.
|
// 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"},
|
{Name: CheckerSeat, Scope: ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"},
|
||||||
|
// The mesh's health, out of the controller (novox/hq ADR 0564 clause 1, health_seats.go): the condition keeper, the
|
||||||
|
// watchman's watchdogs, the self-check and the healers.
|
||||||
|
{Name: ConditionsSeat, Scope: ScopeMesh, Decision: healthDecision,
|
||||||
|
Emits: []string{"condition-raised", "condition-changed", "condition-cleared"}, Serves: conditionsVerbs()},
|
||||||
|
{Name: WatchdogsSeat, Scope: ScopeMesh, Decision: healthDecision, Serves: watchdogsVerbs()},
|
||||||
|
{Name: SelfCheckSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"checked"}, Serves: selfCheckVerbs()},
|
||||||
|
{Name: HealersSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"healer-acted"}, Serves: healersVerbs()},
|
||||||
|
// The control records, out of the controller (novox/hq ADR 0565 clause 1, control_records_seats.go): a person's
|
||||||
|
// policy and the bus step at the assigner, and the hand-act log.
|
||||||
|
{Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
|
||||||
|
{Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
|
||||||
|
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
|
||||||
|
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
|
||||||
|
Serves: artifactSweepVerbs()},
|
||||||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
// 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.
|
// 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.
|
// They keep their names until that migration is done deliberately, apart from the node-* pass.
|
||||||
|
|||||||
@@ -64,6 +64,27 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" {
|
if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" {
|
||||||
t.Errorf("the checker seat serves %v", checkerVerbs)
|
t.Errorf("the checker seat serves %v", checkerVerbs)
|
||||||
}
|
}
|
||||||
|
// The four health seats (novox/hq ADR 0564 clause 1): at mesh scope, each held once, each naming ADR 0564, with the
|
||||||
|
// verbs and events the record names; the events keep the controller's names (clause 3).
|
||||||
|
for _, c := range []struct{ seat, verbs, emits string }{
|
||||||
|
{ConditionsSeat, "conditions,silence,compare,take-over,hand-back,resync", "condition-raised,condition-changed,condition-cleared"},
|
||||||
|
{WatchdogsSeat, "signals,stuck,presence,dead-letters,resync", ""},
|
||||||
|
{SelfCheckSeat, "doctor,probes", "checked"},
|
||||||
|
{HealersSeat, "healers,heal,drill", "healer-acted"},
|
||||||
|
// And mesh-config's switch (ADR 0565 §4), beside its config.
|
||||||
|
{"mesh-config", "config,compare,take-over,hand-back", ""},
|
||||||
|
// And ADR 0565 clause 1's two.
|
||||||
|
{WantsSeat, "upgrade,bus", "policy-changed"},
|
||||||
|
{HandActsSeat, "hand-act,hand-acts,warranted", "act-recorded"},
|
||||||
|
// ADR 0571 clause 2: the artifact store's sweep.
|
||||||
|
{ArtifactSweepSeat, "artifacts,held,collect,mirrors,images", "artifacts-gone"},
|
||||||
|
} {
|
||||||
|
s, ok := SeatNamed(c.seat)
|
||||||
|
if !ok || s.Scope != ScopeMesh || s.Replicated || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 05") ||
|
||||||
|
strings.Join(VerbNames(s.Serves), ",") != c.verbs || strings.Join(s.Emits, ",") != c.emits {
|
||||||
|
t.Errorf("the health seat %s is %v, %+v", c.seat, ok, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
// The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its six
|
// 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.
|
// 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 {
|
if engine, ok := SeatNamed(EngineSeat); !ok || engine.Scope != ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine {
|
||||||
@@ -74,10 +95,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
verbs = append(verbs, v.Name)
|
verbs = append(verbs, v.Name)
|
||||||
}
|
}
|
||||||
// acts reads one act by its id too (ADR 0563 §9: the seat's tools are its command line).
|
// 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 {
|
if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil {
|
||||||
t.Errorf("acts takes no id: %v", engine.Serves[len(engine.Serves)-1].Input)
|
t.Errorf("acts takes no id: %v", engine.Serves[5].Input)
|
||||||
}
|
}
|
||||||
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts" ||
|
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages" ||
|
||||||
strings.Join(engine.Emits, ",") != "asked,done,refused" {
|
strings.Join(engine.Emits, ",") != "asked,done,refused" {
|
||||||
t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits)
|
t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits)
|
||||||
}
|
}
|
||||||
@@ -99,9 +120,11 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
// 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
|
// 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.
|
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||||
// Forty-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1).
|
// 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 {
|
// fifty-three with the four health seats (novox/hq ADR 0564 clause 1); fifty-five with mesh-wants and mesh-hand-acts
|
||||||
t.Errorf("the mesh defines %d seats rather than 49; the set is closed, so a change here is "+
|
// (novox/hq ADR 0565 clause 1); fifty-six with mesh-artifact-sweep (novox/hq ADR 0571 clause 2).
|
||||||
|
if len(Seats()) != 56 {
|
||||||
|
t.Errorf("the mesh defines %d seats rather than 56; the set is closed, so a change here is "+
|
||||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +150,7 @@ func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func claimed(claims string) []byte {
|
func claimed(claims string) []byte {
|
||||||
return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`)
|
return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh","credential":{"form":"none"}}],"claims":` + claims + `}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The refusal moved, it did not go** (novox/hq ADR 0118, superseding 0110). A module may now
|
// **The refusal moved, it did not go** (novox/hq ADR 0118, superseding 0110). A module may now
|
||||||
|
|||||||
@@ -130,22 +130,6 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
|||||||
n.Kept = kept
|
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 {
|
isCarried := func(module, id string) bool {
|
||||||
if module == "" {
|
if module == "" {
|
||||||
module, _ = declaration.ModuleGuess(id)
|
module, _ = declaration.ModuleGuess(id)
|
||||||
@@ -309,106 +293,6 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
|
|||||||
return false
|
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
|
// 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
|
// 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).
|
// alone (issue 1126).
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
"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) }
|
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||||
@@ -97,91 +96,6 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
|||||||
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
|
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
|
||||||
t.Errorf("a node the mesh does not have was composed")
|
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.
|
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
|
||||||
|
|||||||
+116
-150
@@ -3,6 +3,7 @@ package frombus
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -70,7 +71,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
|||||||
return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
|
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)
|
composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps))
|
||||||
if err != nil {
|
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.
|
// 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 {
|
if len(failed) == 0 {
|
||||||
@@ -207,59 +208,24 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// heldBehind is why a module is held with a held one it reads (composeEach's dependents).
|
|
||||||
func heldBehind(m string) string {
|
|
||||||
return "it reads what " + m + " contributes or listens on, and " + m + " is held"
|
|
||||||
}
|
|
||||||
|
|
||||||
// behind is the held module a refusal's words say it is held behind, or "".
|
|
||||||
func behind(why string) string {
|
|
||||||
rest, ok := strings.CutPrefix(why, "it reads what ")
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
m, _, ok := strings.Cut(rest, " contributes or listens on, and ")
|
|
||||||
if !ok || why != heldBehind(m) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
|
// 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), and of each module held only behind one of those: the candidate keeps
|
// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
|
||||||
// them as the node does, which is no change (issue 1126: a seed reproduces today, and today keeps them so). Each says
|
// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
|
||||||
// how it stands today (ModuleRefused.Today).
|
// 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) {
|
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
|
||||||
if len(today) == 0 {
|
if len(today) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
how := map[string]string{}
|
kept := a.Refusals[:0]
|
||||||
for m, w := range today {
|
for _, r := range a.Refusals {
|
||||||
how[m] = w
|
if w, keep := today[r.Module]; keep {
|
||||||
}
|
r.Today = w
|
||||||
for moved := true; moved; {
|
a.Held = append(a.Held, r)
|
||||||
moved = false
|
} else {
|
||||||
kept := a.Refusals[:0]
|
kept = append(kept, r)
|
||||||
for _, r := range a.Refusals {
|
|
||||||
w, keep := how[r.Module]
|
|
||||||
if !keep {
|
|
||||||
if m := behind(r.Why); m != "" {
|
|
||||||
if _, behindKept := how[m]; behindKept {
|
|
||||||
w, keep = "held behind "+m, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if keep {
|
|
||||||
r.Today = w
|
|
||||||
how[r.Module] = w
|
|
||||||
a.Held = append(a.Held, r)
|
|
||||||
moved = true
|
|
||||||
} else {
|
|
||||||
kept = append(kept, r)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
a.Refusals = kept
|
|
||||||
}
|
}
|
||||||
|
a.Refusals = kept
|
||||||
if len(a.Refusals) == 0 {
|
if len(a.Refusals) == 0 {
|
||||||
a.Refusals = nil
|
a.Refusals = nil
|
||||||
}
|
}
|
||||||
@@ -310,15 +276,18 @@ func standsInFor(x, node string) (string, string) {
|
|||||||
// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it
|
// **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
|
// (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
|
// 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 held module is no dependence of its neighbours**
|
// composition is in out: the node keeps what it last applied.
|
||||||
// (issue 1126, the spreading hold): a consumer of what it provides, or a collector of its items, composes against what
|
//
|
||||||
// runs of it like any other module, and is never held for how the held one would compose as its stub (that comparison
|
// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held
|
||||||
// held every postgres consumer behind de-spiegel's hold on novox, three rounds in a row). What it composes is compared
|
// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer,
|
||||||
// with what runs as any module's is: a real difference is still named, and a refusal of its own still refuses it. Only
|
// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what
|
||||||
// what a held module's new manifest says to others, which may not be the build's that runs, is held with it
|
// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the
|
||||||
// (dependents): the providers of what it contributes to in their own files (the proxy its routes), and, when it
|
// build it runs where one is known (ran: a composition of its own running at another build than it is composed at),
|
||||||
// listens, the filter; its items reach their collectors through its own plan, which it keeps as it runs.
|
// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision
|
||||||
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
// (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
|
mode := compose.Foreseeing
|
||||||
if applied {
|
if applied {
|
||||||
mode = compose.Reading
|
mode = compose.Reading
|
||||||
@@ -329,43 +298,46 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
|
|||||||
runs[m] = &running[i]
|
runs[m] = &running[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
src := map[string]catalogue.Manifest{} // each held module's manifest before it was held
|
src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered
|
||||||
bareOf := map[string]bool{}
|
left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut)
|
||||||
var hold func(m, why string, bare bool)
|
hold := func(m, why, leave string) {
|
||||||
hold = func(m, why string, bare bool) {
|
|
||||||
if _, done := failed[m]; !done {
|
if _, done := failed[m]; !done {
|
||||||
failed[m] = why // a held module tried bare keeps the words it was first held for
|
failed[m] = why // a held module tried again keeps the words it was first held for
|
||||||
}
|
}
|
||||||
first := false
|
|
||||||
if _, ok := src[m]; !ok {
|
if _, ok := src[m]; !ok {
|
||||||
man, ok := manifestOn(in, node, m)
|
man, ok := manifestOn(in, node, m)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
src[m], first = man, true
|
if ran != nil {
|
||||||
|
if was, ok := ran(m); ok {
|
||||||
|
man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src[m] = man
|
||||||
}
|
}
|
||||||
bareOf[m] = bareOf[m] || bare
|
if leave != "" {
|
||||||
in = heldAs(in, node, m, src[m], bareOf[m], runs[m])
|
if left[m] == nil {
|
||||||
if !first {
|
left[m] = map[string]bool{}
|
||||||
return
|
}
|
||||||
}
|
left[m][leave] = true
|
||||||
// What the others read of its manifest (what it contributes, what it listens on) is its new manifest's, not
|
if leave != leaveAll {
|
||||||
// necessarily the build's that runs: what reads it is held with it (review of the apply, round 2).
|
failed[m] += "; composed without what it contributes to " + leave + ", which does not compose"
|
||||||
for _, r := range dependents(in, node, src[m], failed) {
|
}
|
||||||
hold(r, heldBehind(m), false)
|
|
||||||
}
|
}
|
||||||
|
in = heldAs(in, node, m, src[m], left[m], runs[m])
|
||||||
}
|
}
|
||||||
for _, m := range sortedKeys(failed) {
|
for _, m := range sortedKeys(failed) {
|
||||||
hold(m, failed[m], false)
|
hold(m, failed[m], "")
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode)
|
out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
module, bare := culprit(in, node, fps, mode, failed, err)
|
module, leave := culprit(in, node, fps, mode, failed, err)
|
||||||
if module == "" {
|
if module == "" {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
hold(module, err.Error(), bare)
|
hold(module, err.Error(), leave)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
again := false
|
again := false
|
||||||
@@ -385,7 +357,7 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
|
|||||||
}
|
}
|
||||||
for _, m := range sortedKeys(of) {
|
for _, m := range sortedKeys(of) {
|
||||||
if _, done := failed[m]; !done {
|
if _, done := failed[m]; !done {
|
||||||
hold(m, "stands in for "+strings.Join(of[m], ", "), false)
|
hold(m, "stands in for "+strings.Join(of[m], ", "), "")
|
||||||
again = true
|
again = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,43 +373,9 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
|
|||||||
return out, skipped, stood, nil
|
return out, skipped, stood, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dependents is, of the modules placed on node and not held, those that read what man says to them: each that provides
|
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); any other word leaves out
|
||||||
// what man contributes to (the proxy its routes), and, when man listens on a port, each that holds the filter (the
|
// what it contributes to that provision alone.
|
||||||
// openings).
|
const leaveAll = "*"
|
||||||
func dependents(in compose.Input, node string, man catalogue.Manifest, failed map[string]string) []string {
|
|
||||||
to := map[string]bool{}
|
|
||||||
for p := range man.Contributes {
|
|
||||||
to[p] = true
|
|
||||||
}
|
|
||||||
for p := range man.ContributesMany {
|
|
||||||
to[p] = true
|
|
||||||
}
|
|
||||||
listens := len(man.Listens) > 0
|
|
||||||
if len(to) == 0 && !listens {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, m := range assignedOn(in, node) {
|
|
||||||
if _, held := failed[m]; held || m == man.Module {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pm, ok := manifestOn(in, node, m)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if listens && pm.Filtering != nil {
|
|
||||||
out = append(out, m)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, o := range pm.Provides {
|
|
||||||
if to[o.Name] {
|
|
||||||
out = append(out, m)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// assignedOn is the modules placed on node, in placement order.
|
// assignedOn is the modules placed on node, in placement order.
|
||||||
func assignedOn(in compose.Input, node string) []string {
|
func assignedOn(in compose.Input, node string) []string {
|
||||||
@@ -462,18 +400,14 @@ func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool
|
|||||||
return man, ok
|
return man, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// culprit is the module placed on node whose composition the error is of: the first, in placement order, whose stub
|
// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
|
||||||
// (compose.Stub) makes the composition in mode succeed or fail otherwise; empty when none does (the error is not one
|
// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
|
||||||
// module's). Modules already refused are passed over. A module whose stub still fails the same is tried bare (its stub
|
// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
|
||||||
// contributing nothing, bare true): a refusal of what it contributes is its own, which the stub keeps (issue 1126,
|
// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
|
||||||
// seed-8: de-spiegel's route names on novox, an unset required value, refused the whole node).
|
// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
|
||||||
func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, bool) {
|
// one module's). Modules already refused are passed over for their stub.
|
||||||
var assigned []string
|
func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) {
|
||||||
for _, n := range in.Nodes {
|
assigned := assignedOn(in, node)
|
||||||
if n.Name == node {
|
|
||||||
assigned = n.Assigned
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a
|
// 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
|
// refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's
|
||||||
// answer took 7m46s).
|
// answer took 7m46s).
|
||||||
@@ -483,65 +417,97 @@ func culprit(in compose.Input, node string, fps map[string]string, mode compose.
|
|||||||
order = append(order, m)
|
order = append(order, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changes := func(m string, bare bool) bool {
|
changes := func(m, leave string) bool {
|
||||||
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, bare), node, fps, mode)
|
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
|
||||||
return again == nil || again.Error() != err.Error()
|
return again == nil || again.Error() != err.Error()
|
||||||
}
|
}
|
||||||
for _, m := range order {
|
for _, m := range order {
|
||||||
if _, done := failed[m]; done {
|
if _, done := failed[m]; done {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if changes(m, false) {
|
if changes(m, "") {
|
||||||
return m, false
|
return m, ""
|
||||||
}
|
|
||||||
}
|
|
||||||
var kept map[string]catalogue.Manifest
|
|
||||||
for _, n := range in.Nodes {
|
|
||||||
if n.Name == node {
|
|
||||||
kept = n.Kept
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, m := range order {
|
for _, m := range order {
|
||||||
// One already held is tried bare too, once: held for its build, its stub may still refuse (its own secret
|
man, ok := manifestOn(in, node, m)
|
||||||
// never issued, issue 1126).
|
if !ok {
|
||||||
if _, done := failed[m]; done && isBare(kept[m]) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if changes(m, true) {
|
// One already held is tried again too: held for its build, its stub may still refuse (its own secret never
|
||||||
return m, true
|
// 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 "", false
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// isBare says a stub contributes nothing and declares no own secret (stubbed, bare).
|
// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll).
|
||||||
func isBare(m catalogue.Manifest) bool {
|
func isBare(m catalogue.Manifest) bool {
|
||||||
return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0
|
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
|
// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node
|
||||||
// composes it from: a culprit's try.
|
// composes it from, leave left out of its offer: a culprit's try.
|
||||||
func stubbed(in compose.Input, node, module string, bare bool) compose.Input {
|
func stubbed(in compose.Input, node, module, leave string) compose.Input {
|
||||||
man, ok := manifestOn(in, node, module)
|
man, ok := manifestOn(in, node, module)
|
||||||
if !ok {
|
if !ok {
|
||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
return heldAs(in, node, module, man, bare, nil)
|
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
|
// 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; bare, contributing nothing to
|
// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of
|
||||||
// what it requires and declaring no own secret: none of a held module's own composition is applied, so what it has not
|
// its offer: a provision's contributions, or with leaveAll everything it contributes and its own secrets, none of a
|
||||||
// been issued (restic's repository on a node it never ran on) is no refusal of the rest.
|
// held module's own composition being applied, so what it has not been issued (restic's repository on a node it never
|
||||||
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, bare bool, runs *delivery.Composed) compose.Input {
|
// 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 := in
|
||||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||||
for i := range out.Nodes {
|
for i := range out.Nodes {
|
||||||
if out.Nodes[i].Name != node {
|
if out.Nodes[i].Name != node {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if bare {
|
if left[leaveAll] {
|
||||||
man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil
|
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)
|
kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1)
|
||||||
for k, v := range out.Nodes[i].Kept {
|
for k, v := range out.Nodes[i].Kept {
|
||||||
|
|||||||
@@ -104,12 +104,12 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
|||||||
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
|
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)
|
t.Errorf("held as today in part: %+v", part)
|
||||||
}
|
}
|
||||||
// A module held only behind one kept today is kept as today too, saying behind which (novox's mailu behind de-spiegel).
|
// 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"},
|
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
|
||||||
{Module: "mailu", Why: heldBehind("de-spiegel")}, {Module: "proxy", Why: heldBehind("mailu")}, {Module: "web", Why: "no build"}}}); true {
|
{Module: "mailu", Why: "no build"}}}); true {
|
||||||
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
|
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
|
||||||
if len(a.Refusals) != 1 || a.Refusals[0].Module != "web" || len(a.Held) != 3 || a.Held[2].Today != "held behind mailu" {
|
if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
|
||||||
t.Errorf("held behind a module kept today: %+v", a)
|
t.Errorf("held as today spreads: %+v", a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,23 +330,26 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
|||||||
// de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values
|
// 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
|
// 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
|
// praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is
|
||||||
// refused, in the config form's words, and proxy, which receives its routes (what it would compose lacks them: review
|
// refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread
|
||||||
// of the apply, round 1); the rest is judged.
|
// 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")
|
spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"},{"name":"smtp","scope":"mesh"}],` +
|
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"}]}`)
|
`"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`)
|
||||||
b = mk("lemurs", overlay.Name, "proxy", "de-spiegel")
|
mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` +
|
||||||
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy} {
|
`"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.manifests[delivery.ManifestDigest(raw)] = raw
|
||||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||||
}
|
}
|
||||||
p = b.placements["n1"]
|
p = b.placements["n1"]
|
||||||
p.Decided.Endpoint = "n1.example:51820"
|
p.Decided.Endpoint = "n1.example:51820"
|
||||||
b.placements["n1"] = p
|
b.placements["n1"] = p
|
||||||
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f")}
|
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)
|
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
|
||||||
refused = nil
|
refused = nil
|
||||||
for _, r := range a.Refusals {
|
for _, r := range a.Refusals {
|
||||||
@@ -355,11 +358,13 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
|||||||
t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why)
|
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", "proxy"}) {
|
if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) {
|
||||||
t.Errorf("a module's own refusal: %+v, want de-spiegel refused with proxy, which receives its routes, the node not", a)
|
t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
|
||||||
}
|
}
|
||||||
if !slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }) {
|
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||||
t.Errorf("lemurs, which composes, is not judged beside de-spiegel's refusal: %+v", a)
|
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
|
// (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It
|
||||||
@@ -393,16 +398,13 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err)
|
t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err)
|
||||||
}
|
}
|
||||||
if len(held) != 2 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["proxy"], "de-spiegel") {
|
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 in its config form's words, and proxy for its routes", held)
|
t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
|
||||||
}
|
}
|
||||||
lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fps["lemurs"])
|
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||||
proxyKey, _ := delivery.ComposedKey("n1", "proxy", fps["proxy"])
|
if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m {
|
||||||
if _, ok := got[lemursKey]; !ok {
|
t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
|
||||||
t.Errorf("lemurs, which composes, is not composed beside de-spiegel's refusal: %v", sortedKeys(got))
|
}
|
||||||
}
|
|
||||||
if _, ok := got[proxyKey]; ok {
|
|
||||||
t.Errorf("proxy is composed without the routes of held de-spiegel: %v", sortedKeys(got))
|
|
||||||
}
|
}
|
||||||
carries := func(c delivery.Composed, what string) {
|
carries := func(c delivery.Composed, what string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -434,7 +436,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
|||||||
// (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others
|
// (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
|
// 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
|
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which
|
||||||
// reads what site's manifest listens on, is held with it (dependents).
|
// reads what site listens on, composes with it (issue 1960: a hold never spreads).
|
||||||
site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
|
site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
|
||||||
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` +
|
`"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"},` +
|
`"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` +
|
||||||
@@ -460,8 +462,12 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
|||||||
json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
|
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"]}`)}}
|
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)
|
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||||
if err != nil || held["site"] == "" || held["nft"] == "" {
|
nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"])
|
||||||
t.Fatalf("site, its own secret never issued: %v, held %v; want site held, and the filter, which depends on it", err, held)
|
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 key, c := range got {
|
||||||
for _, raw := range c.Resources {
|
for _, raw := range c.Resources {
|
||||||
@@ -501,26 +507,25 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
|||||||
t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
|
t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
|
||||||
}
|
}
|
||||||
delete(fps, "app")
|
delete(fps, "app")
|
||||||
// A held feeder of a collector holds no collector: its items reach their collection through its own plan, which it
|
// A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's
|
||||||
// keeps as it runs. Only a provider it contributes to in that provider's own files is held behind it (the proxy its
|
// routes, its names unset, leave the proxy without them, and its smtp account stays with mailu.
|
||||||
// routes), as is the filter where it listens.
|
withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{},
|
||||||
withCollector := compose.Input{Catalogue: map[string]catalogue.Manifest{
|
Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}}
|
||||||
"feeder": {Module: "feeder", Items: []catalogue.Item{{Collection: "routes", Content: "x"}}},
|
feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}},
|
||||||
"col": {Module: "col", Collections: []catalogue.Collection{{Name: "routes"}}},
|
OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}}
|
||||||
"proxy": {Module: "proxy", Provides: []catalogue.Offer{{Name: "route"}}}},
|
for _, row := range []struct {
|
||||||
Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder", "col", "proxy"}}}}
|
left map[string]bool
|
||||||
feeder := withCollector.Catalogue["feeder"]
|
route, smtp, own bool
|
||||||
if d := dependents(withCollector, "n1", feeder, map[string]string{"feeder": "held"}); len(d) != 0 {
|
}{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveAll: true}, false, false, false}} {
|
||||||
t.Errorf("a held feeder of a collector holds %v, want none", d)
|
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 {
|
||||||
feeder.Contributes = map[string]map[string]any{"route": {"name": "x"}}
|
t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets)
|
||||||
if d := dependents(withCollector, "n1", feeder, map[string]string{"feeder": "held"}); !reflect.DeepEqual(d, []string{"proxy"}) {
|
}
|
||||||
t.Errorf("a held module contributing routes holds %v, want the proxy", d)
|
|
||||||
}
|
}
|
||||||
// (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its
|
// (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
|
// manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the
|
||||||
// build that runs listens on 8080: the filter, the holder of what site listens on, is held with it, and nothing
|
// build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
|
||||||
// applied opens 9090.
|
// 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))
|
moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
|
||||||
b.manifests[delivery.ManifestDigest(moved)] = moved
|
b.manifests[delivery.ManifestDigest(moved)] = moved
|
||||||
b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
|
b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
|
||||||
@@ -529,9 +534,15 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
b.placements["n1"] = p
|
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.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)
|
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||||
if err != nil || held["site"] == "" || held["nft"] == "" {
|
if err != nil || held["site"] == "" || held["nft"] != "" {
|
||||||
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held, and the filter with it", err, held)
|
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 key, c := range got {
|
||||||
for _, raw := range c.Resources {
|
for _, raw := range c.Resources {
|
||||||
|
|||||||
@@ -240,12 +240,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
|||||||
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
|
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
parse := func(raw []byte) (catalogue.Manifest, error) {
|
parse := parser(mesh)
|
||||||
if mesh.Predefined != nil {
|
|
||||||
return catalogue.ParseManifestWith(raw, mesh.Predefined)
|
|
||||||
}
|
|
||||||
return catalogue.ParseManifestForInputs(raw)
|
|
||||||
}
|
|
||||||
read := func(module, digest string) (catalogue.Manifest, error) {
|
read := func(module, digest string) (catalogue.Manifest, error) {
|
||||||
raw, err := r.Manifest(ctx, digest)
|
raw, err := r.Manifest(ctx, digest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -313,8 +308,6 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if node == s.Node {
|
if node == s.Node {
|
||||||
// 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 {
|
for _, module := range s.InOwn {
|
||||||
if n.InOwn == nil {
|
if n.InOwn == nil {
|
||||||
n.InOwn = map[string]bool{}
|
n.InOwn = map[string]bool{}
|
||||||
@@ -427,10 +420,47 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
|
|||||||
return nil, nil, held, 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held)
|
out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held, ranOf(ctx, r, mesh, s.Running, fps))
|
||||||
return out, skipped, held, err
|
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
|
// 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
|
// 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
|
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
|
||||||
|
|||||||
@@ -210,12 +210,6 @@ type Node struct {
|
|||||||
// (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own
|
// (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own
|
||||||
// never drops it.
|
// never drops it.
|
||||||
Held map[string]bool
|
Held map[string]bool
|
||||||
// RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the
|
|
||||||
// node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a
|
|
||||||
// build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no
|
|
||||||
// bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost
|
|
||||||
// the folded systemd-resolved, novox's the folded nats).
|
|
||||||
RunsTools map[string][]catalogue.Bundle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place is where a node is, as its placement says (inv.Overlay).
|
// Place is where a node is, as its placement says (inv.Overlay).
|
||||||
|
|||||||
@@ -141,6 +141,15 @@ type FailedFirst struct {
|
|||||||
Test string `json:"test,omitempty"`
|
Test string `json:"test,omitempty"`
|
||||||
Machine string `json:"machine,omitempty"`
|
Machine string `json:"machine,omitempty"`
|
||||||
Words string `json:"words,omitempty"`
|
Words string `json:"words,omitempty"`
|
||||||
|
// Step, Module and Run say where a failed test's whole output is: the build step (its ask's id) of which build
|
||||||
|
// module, under which run (the check), its log on that module's own stream as StepLogEvent(Run, Step), read with
|
||||||
|
// the module's log tool. Absent for a failure that is no test's, and in a verdict written before them.
|
||||||
|
Step string `json:"step,omitempty"`
|
||||||
|
Module string `json:"module,omitempty"`
|
||||||
|
Run string `json:"run,omitempty"`
|
||||||
|
// Pull is the pull request whose check failed, <owner/repo>#<number>: a verdict outlives its candidate, and a reader
|
||||||
|
// finds a pull request's last failure by it.
|
||||||
|
Pull string `json:"pull,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty tests named one by one, and an entry
|
// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty tests named one by one, and an entry
|
||||||
|
|||||||
+4
-13
@@ -3,7 +3,6 @@ package delivery
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -85,6 +84,9 @@ func judged(from, to Composed) (Judgement, string) {
|
|||||||
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
|
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
|
||||||
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
|
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
|
||||||
}
|
}
|
||||||
|
if from.Data != to.Data {
|
||||||
|
return JudgedMovesData, "its data declaration changes"
|
||||||
|
}
|
||||||
places := dataPlaces(before, after)
|
places := dataPlaces(before, after)
|
||||||
held := func(kind string) bool {
|
held := func(kind string) bool {
|
||||||
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
|
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
|
||||||
@@ -102,20 +104,9 @@ func judged(from, to Composed) (Judgement, string) {
|
|||||||
if to.Data || from.Data {
|
if to.Data || from.Data {
|
||||||
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
|
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
|
||||||
}
|
}
|
||||||
// A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29:
|
|
||||||
// a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for
|
|
||||||
// moving its data). It is said loudly in the words, and never hides an item that moved.
|
|
||||||
if len(moved) > 0 {
|
if len(moved) > 0 {
|
||||||
sort.Strings(moved)
|
sort.Strings(moved)
|
||||||
words := "it changes where its data is kept: " + strings.Join(moved, ", ")
|
return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ")
|
||||||
if from.Data != to.Data {
|
|
||||||
words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data)
|
|
||||||
}
|
|
||||||
return JudgedMovesData, words
|
|
||||||
}
|
|
||||||
if from.Data != to.Data {
|
|
||||||
return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+
|
|
||||||
"(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data)
|
|
||||||
}
|
}
|
||||||
return JudgedNeither, "it recreates no container and moves no data"
|
return JudgedNeither, "it recreates no container and moves no data"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ func TestJudgeMove(t *testing.T) {
|
|||||||
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false},
|
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false},
|
||||||
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
|
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
|
||||||
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false},
|
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false},
|
||||||
{"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false},
|
{"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false},
|
||||||
{"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
|
|
||||||
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false},
|
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false},
|
||||||
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false},
|
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false},
|
||||||
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false},
|
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false},
|
||||||
@@ -62,9 +61,6 @@ func TestJudgeMove(t *testing.T) {
|
|||||||
if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
|
if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
|
||||||
t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
|
t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
|
||||||
}
|
}
|
||||||
if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") {
|
|
||||||
t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
|
if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
|
||||||
t.Errorf("the node's own composition: %+v", got)
|
t.Errorf("the node's own composition: %+v", got)
|
||||||
|
|||||||
@@ -253,6 +253,30 @@ type StepAsk struct {
|
|||||||
Compose *ComposeJob `json:"compose,omitempty"`
|
Compose *ComposeJob `json:"compose,omitempty"`
|
||||||
// Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node.
|
// Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node.
|
||||||
Needs []string `json:"needs,omitempty"`
|
Needs []string `json:"needs,omitempty"`
|
||||||
|
// Run is what the ask is part of, one token: the check (its heads hash) or the delivery plan. A build step's log is
|
||||||
|
// said under it and the ask's id (StepLogEvent). Empty in an ask put before it: the log is said under NoRun.
|
||||||
|
Run string `json:"run,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoRun is the run of an ask that names none.
|
||||||
|
const NoRun = "none"
|
||||||
|
|
||||||
|
// StepLogEvent is the event a build step's log is said as on its build module's own stream (BuildLogStream):
|
||||||
|
// log.<run>.<step id>, run NoRun when empty.
|
||||||
|
func StepLogEvent(run, step string) string {
|
||||||
|
if run == "" {
|
||||||
|
run = NoRun
|
||||||
|
}
|
||||||
|
return "log." + run + "." + step
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepLog is one event of a build step's log: its lines in order, Seq the first line's number from 1, so a reader sees
|
||||||
|
// order and gaps (novox/hq ADR 0157 clause 6), and the failure they are of, when they are a failed test's output.
|
||||||
|
type StepLog struct {
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
Package string `json:"package,omitempty"`
|
||||||
|
Test string `json:"test,omitempty"`
|
||||||
|
Lines []string `json:"lines"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer
|
// ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package stdio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module's own stream read back (novox/hq ADR 0363): every module may read the streams it declared under `streams`,
|
||||||
|
// and no other's, by the runtime's stream.read, a batch of direct gets with no consumer, as a log is read. A module's
|
||||||
|
// tools read with it what its stream holds (a build step's log, issue: the full failure output), within the stream's age.
|
||||||
|
|
||||||
|
// StreamEntry is one event of a module's own stream: its sequence, its event's name (the subject after
|
||||||
|
// `mesh.stream.<module>.event.`), its body and when the bus took it.
|
||||||
|
type StreamEntry struct {
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Body json.RawMessage `json:"body"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamPage is one page of a read: its entries in order, the sequence to read on from (0 when nothing follows) and the
|
||||||
|
// stream's last sequence when it was read.
|
||||||
|
type StreamPage struct {
|
||||||
|
Entries []StreamEntry `json:"entries"`
|
||||||
|
Next uint64 `json:"next"`
|
||||||
|
Last uint64 `json:"last"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeptStream is one stream this module declared.
|
||||||
|
type KeptStream struct{ name string }
|
||||||
|
|
||||||
|
// Stream is a stream this module declared, by its local name.
|
||||||
|
func Stream(name string) KeptStream { return KeptStream{name: name} }
|
||||||
|
|
||||||
|
// Read is the events from the sequence from on (0 is the first), whose event names match events (`*` for one name,
|
||||||
|
// `>` for the rest, last; empty is every event the stream carries), oldest first, at most limit (0 is the runtime's
|
||||||
|
// page, 100; at most 1000). Read on from the page's Next until it is 0.
|
||||||
|
func (s KeptStream) Read(events string, from uint64, limit int) (*StreamPage, error) {
|
||||||
|
if limit < 0 {
|
||||||
|
return nil, fmt.Errorf("a read of %s asks for at least no entries: limit %d", s.name, limit)
|
||||||
|
}
|
||||||
|
c, err := live()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params := map[string]any{"stream": s.name, "from": from}
|
||||||
|
if events != "" {
|
||||||
|
params["subject"] = events
|
||||||
|
}
|
||||||
|
if limit > 0 {
|
||||||
|
params["limit"] = limit
|
||||||
|
}
|
||||||
|
raw, err := c.ask("mesh/stream.read", params, logTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p StreamPage
|
||||||
|
return &p, json.Unmarshal(raw, &p)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user