From 40da5e924e677f486109380c09a0b9c04165cec2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 12:53:07 +0100 Subject: [PATCH] A manifest with a field the judge does not know waits instead of being refused; a held module keeps its bus users; tools list and redo the verdicts (ADR 0578 amended, issue 774) --- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 4 +- modules/mesh-delivery-builder/go.mod | 2 +- modules/mesh-delivery-builder/go.sum | 4 +- .../cmd/mesh-delivery-checker/engines.go | 4 +- .../cmd/mesh-delivery-checker/judge_test.go | 6 + modules/mesh-delivery-checker/go.mod | 2 +- modules/mesh-delivery-checker/go.sum | 4 +- modules/mesh-delivery-forge-bridge/go.mod | 2 +- modules/mesh-delivery-forge-bridge/go.sum | 4 +- .../cmd/mesh-delivery-indexer-tools/main.go | 53 +++++- .../cmd/mesh-delivery-indexer/main.go | 4 +- .../cmd/mesh-delivery-indexer/takein.go | 10 +- modules/mesh-delivery-indexer/go.mod | 2 +- modules/mesh-delivery-indexer/go.sum | 4 +- .../internal/index/candidates.go | 2 + .../internal/index/takein.go | 8 +- .../internal/index/toolchains.go | 4 +- .../internal/index/verdicts.go | 171 +++++++++++++++++- .../internal/index/verdicts_test.go | 58 ++++++ modules/mesh-delivery-indexer/judge/judge.go | 22 ++- .../mesh-delivery-indexer/judge/judge_test.go | 15 ++ modules/mesh-delivery-indexer/judge/rules.go | 38 ++++ .../mesh-delivery-indexer/manifest_test.go | 6 +- modules/mesh-delivery-indexer/module.json | 4 +- modules/mesh-delivery-teller/go.mod | 2 +- modules/mesh-delivery-teller/go.sum | 4 +- modules/mesh-delivery-view/go.mod | 2 +- modules/mesh-delivery-view/go.sum | 4 +- modules/mesh-delivery-watchman/go.mod | 2 +- modules/mesh-delivery-watchman/go.sum | 4 +- .../internal/watch/findings_test.go | 12 ++ .../internal/watch/verdicts.go | 21 +++ modules/mesh-enrolment/go.mod | 2 +- modules/mesh-enrolment/go.sum | 4 +- modules/mesh-grants/go.mod | 2 +- modules/mesh-grants/go.sum | 4 +- modules/mesh-grants/grants_test.go | 72 +++++++- modules/mesh-grants/internal/grants/held.go | 120 ++++++++++++ modules/mesh-grants/internal/grants/inputs.go | 17 +- modules/mesh-grants/internal/grants/part.go | 19 +- modules/mesh-grants/internal/grants/sealed.go | 2 +- 42 files changed, 661 insertions(+), 67 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/held.go diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index f0e57eeb..39795ea8 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 43cc4ec7..6de5762d 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index 3fd55d9d..44661726 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-builder go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-builder/go.sum b/modules/mesh-delivery-builder/go.sum index 340e467e..9f9f382b 100644 --- a/modules/mesh-delivery-builder/go.sum +++ b/modules/mesh-delivery-builder/go.sum @@ -1,7 +1,7 @@ git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg= git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go index 22c6de0e..e16b49e0 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go @@ -128,7 +128,9 @@ func (m *manifestVerdicts) put(module string, v *delivery.ManifestVerdict) { delete(m.by, module) return } - m.by[module] = *v + // The sandbox's composer may be the node-engine of an older delivery, which does not know the verdict waits: it is handed a + // wait as the refusal it is held as. + m.by[module] = v.AsRefused() } func (m *manifestVerdicts) all() map[string]delivery.ManifestVerdict { diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go index 24c64d28..0b88b7f0 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go @@ -170,4 +170,10 @@ func TestAManifestFallingBackToAPredefinedKeyIsRead(t *testing.T) { if _, err := parseManifest(manifest); err != nil { t.Errorf("manifestReader: %v", err) } + // The sandbox's composer may be an older node-engine, which knows no verdict waits: it is handed a wait as a refusal. + var held manifestVerdicts + held.put("nats", &delivery.ManifestVerdict{Module: "nats", State: delivery.VerdictManifestWaits, Unknown: []string{"core-credentials"}, Judge: "j"}) + if v := held.all()["nats"]; v.State != delivery.VerdictRefused || len(v.Problems) != 1 || len(v.Unknown) != 0 { + t.Errorf("a waiting verdict is handed to the sandbox as %+v", v) + } } diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index 7024be45..3e680509 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-checker go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-checker/go.sum b/modules/mesh-delivery-checker/go.sum index 340e467e..9f9f382b 100644 --- a/modules/mesh-delivery-checker/go.sum +++ b/modules/mesh-delivery-checker/go.sum @@ -1,7 +1,7 @@ git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg= git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-forge-bridge/go.mod b/modules/mesh-delivery-forge-bridge/go.mod index 94aeabd2..f9796036 100644 --- a/modules/mesh-delivery-forge-bridge/go.mod +++ b/modules/mesh-delivery-forge-bridge/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-forge-bridge/go.sum b/modules/mesh-delivery-forge-bridge/go.sum index 43cc4ec7..6de5762d 100644 --- a/modules/mesh-delivery-forge-bridge/go.sum +++ b/modules/mesh-delivery-forge-bridge/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go index ff97616b..ba265a5a 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go @@ -1,6 +1,7 @@ // Command mesh-delivery-indexer-tools serves the indexer's doing tool (novox/hq issue 1945): the runtime launches it for a // call. indexer_take_in writes one value of the indexer's takeins state and waits for the indexer's answer there. The -// indexer is the other program: it serves no request subject (ADR 0437 §4). The read tools of the indexer's states are +// indexer is the other program: it serves no request subject (ADR 0437 §4); indexer_judge_again asks the same way, and +// indexer_verdicts reads the indexer's verdicts state. The read tools of the indexer's states are // mesh-delivery-view's (indexer_module, indexer_modules, indexer_candidate, indexer_candidates). package main @@ -21,8 +22,8 @@ import ( // asker writes a takeins value and waits for the indexer's answer to it. type asker func(key string, value []byte) (index.TakeInAsk, error) -// Tools are the indexer's tools. -func Tools(ask asker, now func() time.Time) []stdio.Tool { +// Tools are the indexer's tools. read is the indexer's state of verdicts, which indexer_verdicts reads. +func Tools(ask asker, now func() time.Time, read stdio.KeptState) []stdio.Tool { str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } return []stdio.Tool{ {Name: index.TakeInTool, @@ -49,6 +50,50 @@ func Tools(ask asker, now func() time.Time) []stdio.Tool { } return a.Result, nil }}, + {Name: index.VerdictsTool, + Description: "The indexer's verdicts on manifests that wait or are refused (all: every verdict), each as its verdicts state holds it: " + + "the module, the digest, the state (waits: the manifest carries a field the indexer's judge does not know yet, named in " + + "unknown, and every reader holds the module as a refused one and keeps what runs; refused: the judge's problems), the " + + "judge that made it and when. A module's name narrows it to that module. A waiting manifest is judged again at each " + + "new indexer and refused only once the newest build of the indexer on the trunk still does not know the field; " + + "indexer_judge_again judges now.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "module": map[string]any{"type": "string", "description": "only this module's verdicts"}, + "all": map[string]any{"type": "boolean", "description": "list accepted verdicts too"}}}, + Run: func(args map[string]any) (any, error) { + keys, err := read.Keys() + if err != nil { + return nil, fmt.Errorf("the verdicts state cannot be listed: %w", err) + } + all, _ := args["all"].(bool) + rows, unread := index.ListVerdicts(keys, func(key string) ([]byte, error) { + e, err := read.Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil + }, str(args, "module"), all) + return map[string]any{"verdicts": rows, "unread": unread}, nil + }}, + {Name: index.JudgeAgainTool, + Description: "Judge the manifests again now, as the indexer does at its start and after each merge, and answer how the module's " + + "verdict stands (waits, refused or accepted, and why). For a manifest that waits for a judge that knows its fields, or " + + "after a manifest was mended where the indexer has not heard it. A waiting manifest becomes refused here only when the " + + "running indexer is the newest build of itself the trunk holds. indexer_verdicts reads the result.", + Input: map[string]any{"type": "object", "required": []string{"module"}, "properties": map[string]any{ + "module": map[string]any{"type": "string", "description": "the module's name, as the catalogue registers it"}, + "why": map[string]any{"type": "string", "description": "why it is judged again now, in plain words"}}}, + Run: func(args map[string]any) (any, error) { + key, value, err := index.NewJudgeAgain(str(args, "module"), str(args, "why"), stdio.CallerOf(args), now()) + if err != nil { + return nil, err + } + a, err := ask(key, value) + if err != nil { + return nil, err + } + return a.Result, nil + }}, } } @@ -106,7 +151,7 @@ func askTakeIn(s stdio.KeptState, bound time.Duration) asker { } func main() { - if err := stdio.Serve("", Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now)); err != nil { + if err := stdio.Serve("", Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State("verdicts"))); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-indexer-tools] %v\n", err) os.Exit(1) } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go index fdbaabb0..5167a78b 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go @@ -421,8 +421,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro logger.Printf("judging the manifests at start: %v; judged again at the next message heard", err) return } - logger.Printf("judged %d manifests at start: %d verdicts written, %d with problems, of which outgrown and kept accepted: %v", - j.Judged, j.Written, len(j.Refused), j.Outgrown) + logger.Printf("judged %d manifests at start: %d verdicts written, %d with problems, of which outgrown and kept accepted: %v, waiting for a judge that knows their fields: %v", + j.Judged, j.Written, len(j.Refused), j.Outgrown, j.Waiting) }) // A build module says each time it wrote its toolchains key: its stream, and the indexer's durable consumer on it, are // made by the controller as the forge's are. Where the stream is not there yet the indexer says so and is started again diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go index f0c7b024..6ec98671 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go @@ -14,6 +14,7 @@ import ( // takerIn is the part of the indexer a takeins value asks through. type takerIn interface { TakeIn(ctx context.Context, module string) index.TakenIn + JudgeAgain(ctx context.Context, module string) index.TakenIn } // takenIn answers one value of the takeins state (the tool indexer_take_in, issue 1945): a value not yet answered takes the @@ -31,9 +32,14 @@ func takenIn(ctx context.Context, ix takerIn, kv jetstream.KeyValue, e jetstream if !a.Answered.IsZero() { return } - r := ix.TakeIn(ctx, a.Module) + asked, r := "take-in", index.TakenIn{} + if a.Judge { + asked, r = "judging again", ix.JudgeAgain(ctx, a.Module) + } else { + r = ix.TakeIn(ctx, a.Module) + } a.Result, a.Answered = &r, time.Now().UTC() - logf("%s: a take-in asked by %s (%s): %s", a.Module, a.By, a.Why, r.Answer) + logf("%s: a %s asked by %s (%s): %s", a.Module, asked, a.By, a.Why, r.Answer) data, err := json.Marshal(a) if err != nil { return diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index 975387cc..22d6195c 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index 43cc4ec7..6de5762d 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index 68e0bb65..c4100f06 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -107,6 +107,8 @@ type Indexer struct { filling atomic.Bool // a FillTaken runs in the background (KickFill) fillAgain atomic.Bool // a kick arrived while it ran atHeads map[string]map[string]delivery.PublishedPackages // a member head's published Go modules, by repository@head (candidatePublished) + ownMu sync.Mutex // guards ownFP + ownFP string // the indexer's own fingerprint its modules value named at the start pass (ownBuildIsNewest) judgePending atomic.Bool // the start's judge pass failed: judged again at the next message (verdicts.go) } diff --git a/modules/mesh-delivery-indexer/internal/index/takein.go b/modules/mesh-delivery-indexer/internal/index/takein.go index 9d3915fa..b59456b6 100644 --- a/modules/mesh-delivery-indexer/internal/index/takein.go +++ b/modules/mesh-delivery-indexer/internal/index/takein.go @@ -25,9 +25,11 @@ const ( // TakeInAsk is a value of the takeins state: which module a person asked to be taken in, why, who and when, and, once the // indexer read it, its answer. type TakeInAsk struct { - V int `json:"v"` - Module string `json:"module,omitempty"` - Why string `json:"why,omitempty"` + V int `json:"v"` + Module string `json:"module,omitempty"` + Why string `json:"why,omitempty"` + // Judge asks the manifests to be judged again now (indexer_judge_again) instead of the module to be taken in. + Judge bool `json:"judge,omitempty"` By string `json:"by,omitempty"` At time.Time `json:"at"` Result *TakenIn `json:"result,omitempty"` diff --git a/modules/mesh-delivery-indexer/internal/index/toolchains.go b/modules/mesh-delivery-indexer/internal/index/toolchains.go index 1789b439..51f668e4 100644 --- a/modules/mesh-delivery-indexer/internal/index/toolchains.go +++ b/modules/mesh-delivery-indexer/internal/index/toolchains.go @@ -353,7 +353,7 @@ func (ix *Indexer) writeOverlay(ctx context.Context, overlay, finals map[string] if held && cur.Manifest == next.Manifest { // The judge's verdict stands while the digest does (ADR 0578 §3, ADR 0579 §1): a recompute never writes a // refused or outgrown module as accepted; only the judge changes it. - next.Verdict, next.Problems, next.Warnings = cur.Verdict, cur.Problems, cur.Warnings + next.Verdict, next.Problems, next.Unknown, next.Warnings = cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings } if held && sameButWhere(cur, next) { return nil, false, nil @@ -380,7 +380,7 @@ func (ix *Indexer) writeOverlay(ctx context.Context, overlay, finals map[string] func sameButWhere(cur, next delivery.Module) bool { next = withV(next) next.Commit, next.Src = cur.Commit, cur.Src - next.Verdict, next.Problems, next.Warnings = cur.Verdict, cur.Problems, cur.Warnings + next.Verdict, next.Problems, next.Unknown, next.Warnings = cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings return reflect.DeepEqual(cur, next) } diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts.go b/modules/mesh-delivery-indexer/internal/index/verdicts.go index 291562d6..cbcd278c 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts.go @@ -7,6 +7,8 @@ import ( "fmt" "maps" "slices" + "strings" + "time" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -18,7 +20,7 @@ import ( // The indexer judges every manifest it holds, once, with its own judge (novox/hq ADR 0578 §1): it writes each verdict in // its state verdicts (delivery.VerdictsBucket), one key per manifest digest, only when the verdict differs from the one // held; a module's value in the modules state carries a refusal (Verdict, Problems), so a reader of modules takes it -// without a second read; a new refusal is said as the event manifest-refused. It judges at its start, so a judge that +// without a second read; a new refusal is said as the event manifest-refused; a manifest with a field the judge does not know waits instead (ownBuildIsNewest). It judges at its start, so a judge that // changed (a new indexer) judges every manifest again, and after every merge it takes in, since a rule between manifests // can turn on any of them. Nothing polls. @@ -31,6 +33,8 @@ type Judged struct { Refused map[string][]string // Outgrown are the modules accepted before whose problems are now warnings (ADR 0579 §1). Outgrown []string + // Waiting are the modules whose manifest carries a field this judge does not know and waits. + Waiting []string } // JudgeAll judges every manifest the modules state names, as one catalogue with the predefined list the indexer keeps, @@ -74,11 +78,15 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, raws[name] = []byte(kept.JSON) digests[name] = digest } - refused, unnamed := judge.Shelf(raws, ix.predefinedList(ctx), 0) + refused, waits, unnamed := judge.ShelfWaiting(raws, ix.predefinedList(ctx), 0) for _, p := range unnamed { ix.logf("judge: a problem between the manifests that no one module's removal ends, so it refuses none: %s", p) } out.Refused = refused + // A manifest with a field this judge does not know waits for a judge that may know it (clause 3 of the record on a + // release's Approve; ADR 0578 §1): it is refused only when this program is the newest build of the indexer the trunk + // holds, so no judge that could know the field is still to come. + newest := ix.ownBuildIsNewest(held) id := ix.JudgeID if id == "" { id = judge.Identity() @@ -86,7 +94,21 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, for _, name := range slices.Sorted(maps.Keys(digests)) { out.Judged++ v := delivery.ManifestVerdict{Module: name, State: delivery.VerdictAccepted, Judge: id, At: ix.now()} - if problems := refused[name]; len(problems) > 0 { + if fields := waits[name]; len(fields) > 0 { + accepted := ix.acceptedBefore(ctx, digests[name], fresh) + if !newest && !accepted { + v.State, v.Unknown = delivery.VerdictManifestWaits, fields + out.Waiting = append(out.Waiting, name) + } else { + _, problems := judge.Manifest(raws[name], ix.predefinedList(ctx)) + if accepted { + v.Warnings = problems + out.Outgrown = append(out.Outgrown, name) + } else { + v.State, v.Problems = delivery.VerdictRefused, problems + } + } + } else if problems := refused[name]; len(problems) > 0 { if ix.acceptedBefore(ctx, digests[name], fresh) { v.Warnings = problems out.Outgrown = append(out.Outgrown, name) @@ -104,6 +126,9 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, } if wrote { out.Written++ + if v.Waits() { + ix.logf("judge: %s", v.Why()) + } if v.Refused() { ix.logf("judge: %s", v.Why()) if ix.SayRefused != nil { @@ -121,6 +146,11 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, // JudgeAtStart is JudgeAll of every manifest held, none fresh, as the indexer starts (a judge that changed judges every // one again); one that fails is judged again at the next message or source revision heard (kickJudge). func (ix *Indexer) JudgeAtStart(ctx context.Context) (Judged, error) { + if held, err := ix.heldModules(ctx); err == nil { + ix.ownMu.Lock() + ix.ownFP = held[delivery.IndexerModule].FP2 // this program's build, until the trunk names another + ix.ownMu.Unlock() + } j, err := ix.JudgeAll(ctx, nil) ix.judgePending.Store(err != nil) return j, err @@ -149,7 +179,7 @@ func (ix *Indexer) acceptedBefore(ctx context.Context, digest string, fresh map[ return !fresh[digest] } prev, err := delivery.DecodeManifestVerdict(value) - return err == nil && !prev.Refused() + return err == nil && prev.State == delivery.VerdictAccepted } // writeVerdict writes v under the digest's key unless the verdict held there says the same (ManifestVerdict.Same); wrote @@ -173,6 +203,21 @@ func (ix *Indexer) writeVerdict(ctx context.Context, digest string, v delivery.M return wrote, err } +// ownBuildIsNewest says whether the build of the indexer this program is, is the newest the trunk holds: nothing gives a +// module the fingerprint it was built from, so it is the one the indexer's own modules value named when this program +// judged at its start (ownFP), and the trunk holds no newer one while that value still names it. A newer build of the +// indexer on the trunk, as one that pins a changed mesh-sdk always is, ends it at the trunk's merge, before it runs; false +// when the value names none or this program judged no start pass. +func (ix *Indexer) ownBuildIsNewest(held map[string]delivery.Module) bool { + fp := held[delivery.IndexerModule].FP2 + if fp == "" { + return false + } + ix.ownMu.Lock() + defer ix.ownMu.Unlock() + return ix.ownFP != "" && ix.ownFP == fp +} + // carryVerdict puts a refusal into the module's value, and takes one out once the manifest is accepted, while the value // still names the digest judged; an accepted module's value carries nothing, so judging writes no value of a module the // judge accepts (a reader takes a value without a verdict as accepted, delivery.TakeManifestVerdict). @@ -189,14 +234,18 @@ func (ix *Indexer) carryVerdict(ctx context.Context, name, digest string, v deli if err != nil || cur.Manifest != digest { return nil, false, nil // gone, unreadable, or moved to another manifest since: the next pass judges it } - state := "" - if v.Refused() { + // A wait is written as a refusal naming the fields, so a reader pinned before the verdict waits holds it as refused. + state, problems := "", v.Problems + switch { + case v.Waits(): + state, problems = delivery.VerdictRefused, []string{v.WaitsOn()} + case v.Refused(): state = delivery.VerdictRefused } - if cur.Verdict == state && slices.Equal(cur.Problems, v.Problems) && slices.Equal(cur.Warnings, v.Warnings) { + if cur.Verdict == state && slices.Equal(cur.Problems, problems) && slices.Equal(cur.Unknown, v.Unknown) && slices.Equal(cur.Warnings, v.Warnings) { return nil, false, nil } - cur.Verdict, cur.Problems, cur.Warnings = state, v.Problems, v.Warnings + cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings = state, problems, v.Unknown, v.Warnings if state == "" { cur.Problems = nil } @@ -226,3 +275,109 @@ func (ix *Indexer) predefinedList(ctx context.Context) catalogue.PredefinedList } return list } + +// The tools that read and steer the verdicts (the instruction "tools are how a module is spoken to"): indexer_verdicts +// lists the manifests that wait or are refused, and indexer_judge_again judges one module again now. +const ( + VerdictsTool = "indexer_verdicts" + JudgeAgainTool = "indexer_judge_again" + judgeAgainPrefix = "judge." +) + +// VerdictRow is one verdict as indexer_verdicts lists it: the manifest's digest, the module, its state, the fields it waits +// on or the problems that refuse it, and the judge that made it. +type VerdictRow struct { + Module string `json:"module"` + Digest string `json:"digest"` + State string `json:"state"` + Words string `json:"words,omitempty"` + Unknown []string `json:"unknown,omitempty"` + Problems []string `json:"problems,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Judge string `json:"judge"` + At time.Time `json:"at"` +} + +// ListVerdicts reads the verdicts under keys with get and lists those of module (every module when empty) that wait or are +// refused, or every one with all, sorted by module and digest. A value that does not read is said in unread by its key. +func ListVerdicts(keys []string, get func(key string) ([]byte, error), module string, all bool) (rows []VerdictRow, unread map[string]string) { + unread = map[string]string{} + for _, key := range keys { + value, err := get(key) + if err != nil || value == nil { + if err != nil { + unread[key] = err.Error() + } + continue + } + v, err := delivery.DecodeManifestVerdict(value) + if err != nil { + unread[key] = err.Error() + continue + } + if (module != "" && v.Module != module) || (!all && !v.Held()) { + continue + } + rows = append(rows, VerdictRow{Module: v.Module, Digest: key, State: v.State, Words: v.Why(), Unknown: v.Unknown, + Problems: v.Problems, Warnings: v.Warnings, Judge: v.Judge, At: v.At}) + } + slices.SortFunc(rows, func(a, b VerdictRow) int { + if c := strings.Compare(a.Module, b.Module); c != 0 { + return c + } + return strings.Compare(a.Digest, b.Digest) + }) + return rows, unread +} + +// JudgeAgain judges the manifests again now, as at the start, and answers how the module's stands: the tool +// indexer_judge_again. A module the modules state does not hold, or whose manifest is not in the manifests state, is +// answered so. +func (ix *Indexer) JudgeAgain(ctx context.Context, module string) TakenIn { + out := TakenIn{Module: module} + held, err := ix.heldModules(ctx) + if err != nil { + out.Answer = "the modules state cannot be read: " + err.Error() + return out + } + m, ok := held[module] + if !ok || m.Manifest == "" { + out.Answer = "the indexer holds no manifest of " + module + return out + } + if _, err := ix.JudgeAll(ctx, nil); err != nil { + out.Answer = "the manifests could not be judged: " + err.Error() + return out + } + value, _, found, err := ix.Verdicts.Get(ctx, m.Manifest) + if err != nil || !found { + out.Answer = "judged, but the verdict on " + module + " cannot be read" + return out + } + v, err := delivery.DecodeManifestVerdict(value) + if err != nil { + out.Answer = "judged, but the verdict on " + module + " cannot be read: " + err.Error() + return out + } + out.Stored = true + out.Answer = "judged again by " + v.Judge + ": " + v.State + if v.Held() { + out.Answer += " (" + v.Why() + ")" + } + return out +} + +// NewJudgeAgain is the value indexer_judge_again writes for a module: its key in the takeins state and the value. +func NewJudgeAgain(module, why, by string, at time.Time) (string, []byte, error) { + key, data, err := NewTakeIn(module, why, by, at) + if err != nil { + return "", nil, err + } + var a TakeInAsk + if err := json.Unmarshal(data, &a); err != nil { + return "", nil, err + } + a.Judge = true + data, err = json.Marshal(a) + return judgeAgainPrefix + key, data, err +} diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go index df57baa5..33166028 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go @@ -118,6 +118,64 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { if err := ix.writeOverlay(ctx, map[string]delivery.Module{"old": same}, nil, w); err != nil || !w.fresh[other] { t.Fatalf("a changed digest is not fresh: %v %v", w.fresh, err) } + // Clause 3 of the record on a release's Approve: a manifest with a field this judge does not know waits (no refusal, no + // event) while a newer build of the indexer is on the trunk, and is judged again at the next judge; once this program's + // build is the trunk's newest it is refused; a wait is written into the module's value as a refusal naming the fields. + ownFP := "src2:" + strings.Repeat("e", 64) + hold(delivery.IndexerModule, `{"module":"mesh-delivery-indexer","version":"1"}`) + ix.ownFP = ownFP // this program started when the trunk held another build of the indexer than the one it holds now + later := hold("later", `{"module":"later","version":"1","a-field-from-later":1,"another":{"x":1}}`) + said = nil + before := len(said) + j, err = ix.JudgeAll(ctx, map[string]bool{later: true}) + if err != nil || len(j.Waiting) != 1 || j.Waiting[0] != "later" || len(said) != before { + t.Fatalf("a manifest with fields from later: %+v %v, said %d", j, err, len(said)) + } + if v := verdict(later); !v.Waits() || v.Refused() || strings.Join(v.Unknown, ",") != "a-field-from-later,another" { + t.Fatalf("later: %+v", v) + } + if m := module("later"); m.Verdict != delivery.VerdictRefused || len(m.Unknown) != 2 || !m.VerdictOf("later").Waits() || + !strings.Contains(m.Problems[0], "does not know yet") { + t.Fatalf("modules.later carries %+v", m) + } + // A renamed field is a known mistake: refused at once. + renamed := hold("renamed", `{"module":"renamed","version":"1","needs":{"x":"y"}}`) + if j, err := ix.JudgeAll(ctx, map[string]bool{renamed: true}); err != nil || len(j.Waiting) != 1 || !verdict(renamed).Refused() { + t.Fatalf("a renamed field: %+v %v", j, err) + } + // The trunk's newest build of the indexer is this program's: the field is one no judge to come knows. + ix.ownFP = "src2:" + strings.Repeat("a", 64) + if j, err := ix.JudgeAll(ctx, nil); err != nil || len(j.Waiting) != 0 || !verdict(later).Refused() || !strings.Contains(verdict(later).Problems[0], "a-field-from-later") { + t.Fatalf("the newest build refuses: %+v %v %+v", j, err, verdict(later)) + } + if m := module("later"); m.Verdict != delivery.VerdictRefused || m.VerdictOf("later").Waits() || len(m.Unknown) != 0 { + t.Fatalf("modules.later after the refusal carries %+v", m) + } + // The tools: indexer_verdicts lists the verdicts that wait or are refused (and every one with all), indexer_judge_again + // judges now and says how the module's verdict stands. + keys, _ := ix.Verdicts.Keys(ctx) + get := func(key string) ([]byte, error) { v, _, _, err := ix.Verdicts.Get(ctx, key); return v, err } + if rows, unread := ListVerdicts(keys, get, "later", false); len(rows) != 1 || rows[0].State != delivery.VerdictRefused || len(unread) != 0 { + t.Fatalf("indexer_verdicts of later: %+v %v", rows, unread) + } + if rows, _ := ListVerdicts(keys, get, "", true); len(rows) <= len(keys)-3 || rows[0].Module > rows[len(rows)-1].Module { + t.Fatalf("indexer_verdicts, all: %d rows of %d verdicts", len(rows), len(keys)) + } + ix.ownFP = ownFP + if got := ix.JudgeAgain(ctx, "later"); !got.Stored || !strings.Contains(got.Answer, "waits") || !strings.Contains(got.Answer, "a-field-from-later") { + t.Fatalf("indexer_judge_again: %+v", got) + } + if rows, _ := ListVerdicts(keys, get, "later", false); len(rows) != 1 { + t.Fatalf("indexer_verdicts of later after: %+v", rows) + } + if got := ix.JudgeAgain(ctx, "nobody"); got.Stored || !strings.Contains(got.Answer, "holds no manifest") { + t.Fatalf("indexer_judge_again of nobody: %+v", got) + } + if key, value, err := NewJudgeAgain("later", "why", "me", time.Now()); err != nil || key != "judge.later" { + t.Fatalf("the ask: %q %v", key, err) + } else if a, err := DecodeTakeIn(value); err != nil || !a.Judge || a.Module != "later" { + t.Fatalf("the ask's value: %+v %v", a, err) + } // Without the verdicts state the indexer judges nothing. ix.Verdicts = nil if j, err := ix.JudgeAll(ctx, nil); err != nil || j.Judged != 0 { diff --git a/modules/mesh-delivery-indexer/judge/judge.go b/modules/mesh-delivery-indexer/judge/judge.go index 3add6332..f58dc8aa 100644 --- a/modules/mesh-delivery-indexer/judge/judge.go +++ b/modules/mesh-delivery-indexer/judge/judge.go @@ -69,9 +69,27 @@ func problemsOf(err error) []string { // whose removal from the catalogue ends it (a rule two modules break together refuses both); one no removal ends is in // unnamed, for the caller to say. A module not in the answer is accepted. func Shelf(raws map[string][]byte, predefined catalogue.PredefinedList, longestMachine int) (refused map[string][]string, unnamed []string) { - refused = map[string][]string{} + refused, waits, unnamed := ShelfWaiting(raws, predefined, longestMachine) + for name := range waits { + // Strictly, as the checker judges a change: the judge that runs there is the one built at the change's head, which + // knows the fields the change brings, so a field it does not know is a mistake. + _, refused[name] = Manifest(raws[name], predefined) + } + return refused, unnamed +} + +// ShelfWaiting is Shelf where a manifest that carries a field this judge does not know (UnknownFields) is not refused: it is +// in waits, by module, naming each field, and judged no further, because a newer judge may know the field (novox/hq ADR +// 0578 §1, amended). The indexer judges so; the checker, which runs the judge built at the head of the change, judges with +// Shelf. +func ShelfWaiting(raws map[string][]byte, predefined catalogue.PredefinedList, longestMachine int) (refused, waits map[string][]string, unnamed []string) { + refused, waits = map[string][]string{}, map[string][]string{} shelf := catalogue.Shelf{} for name, raw := range raws { + if unknown := UnknownFields(raw); len(unknown) > 0 { + waits[name] = unknown + continue + } m, problems := Manifest(raw, predefined) if len(problems) > 0 { refused[name] = problems @@ -122,7 +140,7 @@ func Shelf(raws map[string][]byte, predefined catalogue.PredefinedList, longestM for name := range refused { sort.Strings(refused[name]) } - return refused, unnamed + return refused, waits, unnamed } // Between is every problem between the manifests of shelf, sorted: catalogue.CatalogueProblems, IdentityProblems over diff --git a/modules/mesh-delivery-indexer/judge/judge_test.go b/modules/mesh-delivery-indexer/judge/judge_test.go index 8243b338..24925780 100644 --- a/modules/mesh-delivery-indexer/judge/judge_test.go +++ b/modules/mesh-delivery-indexer/judge/judge_test.go @@ -54,6 +54,21 @@ func TestTheJudge(t *testing.T) { } } } + // A field the judge does not know waits in the indexer's judge and stays a refusal in the checker's (clause 3 of the + // record on a release's Approve); a renamed field and any other problem are refused by both. + waitRaws := map[string][]byte{ + "later": []byte(`{"module":"later","version":"1","core-credentials":true,"other":1}`), + "renamed": []byte(`{"module":"renamed","version":"1","needs":{"x":"y"}}`), + "bad": []byte(`{"module":"bad","version":"1","provides":[{"name":"amqp"}]}`), + "ok": []byte(`{"module":"ok","version":"1"}`), + } + refused, waits, _ := ShelfWaiting(waitRaws, nil, 0) + if !reflect.DeepEqual(waits, map[string][]string{"later": {"core-credentials", "other"}}) || len(refused) != 2 || refused["renamed"] == nil || refused["bad"] == nil { + t.Errorf("ShelfWaiting: waits %v, refused %v", waits, refused) + } + if strict, _ := Shelf(waitRaws, nil, 0); len(strict) != 3 || !strings.Contains(strings.Join(strict["later"], ""), "core-credentials") { + t.Errorf("Shelf refuses as before: %v", strict) + } // The private network's manifest, which the mesh provides itself, is accepted (moved from mesh-sdk overlay's test). if raw, err := json.Marshal(overlay.Manifest()); err != nil { t.Fatal(err) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index c26cca36..e18b6cae 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -2388,6 +2388,44 @@ func ownSecretPromptProblems(m catalogue.Manifest, name, prompt string, family b return problems } +// unknownFieldText is how the lenient decoder words the first field its shape does not have, and readWithout the list of +// every field it read the manifest without (catalogue.Manifest.SetAside). +var ( + unknownFieldText = regexp.MustCompile(`json: unknown field "([^"]+)"`) + readWithout = regexp.MustCompile(`\(read without ([^)]*)\)`) +) + +// UnknownFields are the fields of a manifest the judge does not know, as the lenient decoder sets them aside (catalogue.Decode, +// each once, the path it names them with), or none: a manifest this judge reads whole has none, and a field that used to +// mean something (renamed) is a known mistake, not a field a later judge may know, so a manifest with one has none +// either. +func UnknownFields(raw []byte) []string { + m, err := catalogue.Decode(raw) + aside := m.SetAside() + if err != nil || aside == "" { + return nil + } + first := unknownFieldText.FindStringSubmatch(aside) + if first == nil { + return nil // set aside for another reason: the judge's refusal as it is + } + fields := []string{first[1]} + if list := readWithout.FindStringSubmatch(aside); list != nil { + fields = fields[:0] + for _, f := range strings.Split(list[1], ", ") { + if f = strings.TrimSpace(f); f != "" && !slices.Contains(fields, f) { + fields = append(fields, f) + } + } + } + for _, f := range fields { + if _, was := renamed[f[strings.LastIndex(f, ".")+1:]]; was { + return nil + } + } + return fields +} + func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.Manifest, error) { var m catalogue.Manifest // Strictly. **An unknown key is refused**, which is the discipline the host's declaration diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index c03a9702..bb9343db 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -112,8 +112,10 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build if want := []string{"gitea.gitea_commit_trees", "gitea.gitea_contains", "gitea.gitea_get_file", "gitea.gitea_open_pulls"}; !reflect.DeepEqual(m.Invokes, want) { t.Errorf("invokes = %v, want %v", m.Invokes, want) } - // 1945: indexer_take_in, served by its tools bundle through the takeins state; the process serves none. - if want := []string{"indexer_take_in"}; !reflect.DeepEqual(m.Tools, want) { + // 1945: indexer_take_in, served by its tools bundle through the takeins state; the process serves none. The same bundle + // reads the verdicts state (indexer_verdicts) and asks the indexer to judge again through the takeins state + // (indexer_judge_again; issue 774, clause 3). + if want := []string{"indexer_take_in", "indexer_verdicts", "indexer_judge_again"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } // No streams, no bus, no seat (ADR 0437 §4); it says no event. diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index e8c38862..2f4bc361 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -3,7 +3,9 @@ "version": "1", "slug": "indexer", "tools": [ - "indexer_take_in" + "indexer_take_in", + "indexer_verdicts", + "indexer_judge_again" ], "consumes": [ "mesh-build-go.toolchain.reported", diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index b27cca6b..721c8ed5 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index 43cc4ec7..6de5762d 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index faf8b6ba..499bb922 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index 43cc4ec7..6de5762d 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-watchman/go.mod b/modules/mesh-delivery-watchman/go.mod index 9eafb632..bd9b3bf2 100644 --- a/modules/mesh-delivery-watchman/go.mod +++ b/modules/mesh-delivery-watchman/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-watchman go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-watchman/go.sum b/modules/mesh-delivery-watchman/go.sum index 1582a407..6e5f2993 100644 --- a/modules/mesh-delivery-watchman/go.sum +++ b/modules/mesh-delivery-watchman/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-watchman/internal/watch/findings_test.go b/modules/mesh-delivery-watchman/internal/watch/findings_test.go index 573abe74..3e94a361 100644 --- a/modules/mesh-delivery-watchman/internal/watch/findings_test.go +++ b/modules/mesh-delivery-watchman/internal/watch/findings_test.go @@ -59,6 +59,18 @@ func TestTheWatchmansFindings(t *testing.T) { t.Fatalf("findings = %v", o.findings) } w.ModuleVerdict("node-tools", value(""), Put) + // Clause 3 of the record on a release's Approve: a manifest that waits for a judge that knows its fields is its own + // warning (a refusal in the value, naming the fields), not manifest-refused, and goes when it is accepted. + waiting, _ := delivery.EncodeModule(delivery.Module{FP2: "src2:" + strings.Repeat("a", 64), Manifest: strings.Repeat("b", 64), + Repository: "novox/mesh-catalog", Commit: "c", Verdict: delivery.VerdictRefused, Problems: []string{"waits"}, Unknown: []string{"core-credentials"}}) + w.ModuleVerdict("nats", waiting, Put) + if f := o.findings[FindingKey(ScopeModule, "nats", KindManifestWaits)]; f.Severity != Warning || !strings.Contains(f.Summary, "core-credentials") { + t.Fatalf("findings = %v", o.findings) + } + if _, refused := o.findings[FindingKey(ScopeModule, "nats", KindManifestRefused)]; refused { + t.Fatalf("a waiting manifest was found refused: %v", o.findings) + } + w.ModuleVerdict("nats", value(""), Put) }, map[string]string{}}, {"WM4: a stuck key not moved is its finding, and its move deletes it", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) { w.Step("go", "n1.ask1", runningStep("ask1", t0), Put) diff --git a/modules/mesh-delivery-watchman/internal/watch/verdicts.go b/modules/mesh-delivery-watchman/internal/watch/verdicts.go index 1a80e65c..b31b648e 100644 --- a/modules/mesh-delivery-watchman/internal/watch/verdicts.go +++ b/modules/mesh-delivery-watchman/internal/watch/verdicts.go @@ -14,6 +14,11 @@ const KindManifestRefused = "manifest-refused" // (novox/hq ADR 0579 §1): it stays accepted and runs; the warning stands until the manifest is mended. const KindManifestOutgrown = delivery.ManifestOutgrown +// KindManifestWaits is a module whose manifest carries a field the indexer's judge does not know yet (novox/hq ADR 0578 +// §1, amended): every reader holds it as a refused one and keeps what runs, and the warning stands until a judge knows the +// field or the manifest is mended. +const KindManifestWaits = delivery.ManifestWaits + // ModuleVerdict is a change of the indexer's value of a module (delivery.ModulesBucket): a value carrying a refusal is a // finding, one that does not (accepted, or not judged yet) or a deleted key ends it. func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) { @@ -21,9 +26,11 @@ func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) { defer w.mu.Unlock() key := FindingKey(ScopeModule, module, KindManifestRefused) outgrown := FindingKey(ScopeModule, module, KindManifestOutgrown) + waits := FindingKey(ScopeModule, module, KindManifestWaits) if op != Put { w.unfind(key) w.unfind(outgrown) + w.unfind(waits) return } m, err := delivery.DecodeModule(value) @@ -32,6 +39,20 @@ func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) { return } v := m.VerdictOf(module) + if v != nil && v.Waits() { + w.unfind(key) + w.unfind(outgrown) + w.find(Finding{Scope: ScopeModule, ID: module, Kind: KindManifestWaits, Severity: Warning, Source: delivery.ModulesBucket, + Summary: v.Why(), + Said: strings.Join(v.Unknown, "; "), + Headline: "The manifest of " + module + " waits for a judge that knows its fields", + Explanation: "The manifest carries " + strings.Join(v.Unknown, ", ") + ", which the indexer's judge does not know yet, so every " + + "reader holds " + module + " as a refused one and keeps what runs; an indexer that knows the fields judges it again " + + "at its start, and the newest build of the indexer refuses a field it does not know.", + Needs: "nothing while a newer indexer is on its way; else a pull request that mends the manifest of " + module}) + return + } + w.unfind(waits) if refused, _ := delivery.TakeManifestVerdict(v); !refused { w.unfind(key) if v == nil || len(v.Warnings) == 0 { diff --git a/modules/mesh-enrolment/go.mod b/modules/mesh-enrolment/go.mod index d6d91c02..3ea0d929 100644 --- a/modules/mesh-enrolment/go.mod +++ b/modules/mesh-enrolment/go.mod @@ -3,7 +3,7 @@ module mesh-enrolment go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-enrolment/go.sum b/modules/mesh-enrolment/go.sum index 43cc4ec7..6de5762d 100644 --- a/modules/mesh-enrolment/go.sum +++ b/modules/mesh-enrolment/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index 75906a2e..20f6a617 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,7 +3,7 @@ module mesh-grants go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-grants/go.sum b/modules/mesh-grants/go.sum index 43cc4ec7..6de5762d 100644 --- a/modules/mesh-grants/go.sum +++ b/modules/mesh-grants/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004 h1:RBb1K9hAGBB58erfTx02hMVVE1s8TXL4YI4iCB/0cKU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103115157-8e1043ed4004/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 27c2d4b4..51dfa914 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -1487,7 +1487,7 @@ func TestGrants(t *testing.T) { fp := partOn(b) fp.Objects = p.Objects fp.Say = func(c asks.Condition) error { - if c.Key != grants.GrantMissingCondition && c.Key != grants.RefusedCondition { // their own rows + if c.Key != grants.GrantMissingCondition && c.Key != grants.RefusedCondition && c.Key != grants.HeldModuleCondition { // their own rows fresh = append(fresh, c) } return nil @@ -2369,7 +2369,7 @@ func TestGrants(t *testing.T) { var said, behind []asks.Condition p.Say = func(c asks.Condition) error { switch c.Key { - case grants.GrantMissingCondition, grants.RefusedCondition, grants.FullCondition: // their own rows + case grants.GrantMissingCondition, grants.RefusedCondition, grants.HeldModuleCondition, grants.FullCondition: // their own rows case grants.ConsumerBehindCondition: behind = append(behind, c) default: @@ -2668,6 +2668,74 @@ func TestGrants(t *testing.T) { t.Errorf("the refusal was not cleared once it composes: %v, said %+v", res.PrincipalsRefused, said) } }) + + // Clause 3 of the record on a release's Approve (issue 774): a module whose manifest waits for a judge that knows a field + // of it, or is refused, and whose want names no build, keeps its users of the list in force and is said in a condition; + // the rest of the list is written as ever, and the users go the day the manifest is accepted and the module unassigned. + t.Run("a module whose manifest waits or is refused keeps its users of the list in force", func(t *testing.T) { + b := fixture() + const ctl = `{"module":"mod-r","version":"1","own-secrets":{"broker":"/var/lib/r/broker","broker-mod-r-tools":"${dir:tools-secret}/broker-mod-r-tools"},` + + `"resources":[{"id":"tools-secret","type":"directory","path":"/var/lib/mesh/mod-r-tools","mode":"0711"}],"build":{"artifacts":[{"name":"tools",` + + `"kind":"bundle","language":"go","system":"arch","from":"cmd/mod-r-tools","binary":"mod-r-tools","loads":["mod-r-tools"],"bus":{}}]}}` + b.manifestOf("mod-r", ctl) + b.assign("b", "mod-r") + for _, u := range shadowOf(t, b) { + b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"}) + } + b.put(grants.ReadAccounts, "b.mod-r.mod-r-tools", map[string]any{"user": "b.mod-r.mod-r-tools", "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"}) + busKey, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK, + UserList: &delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Digest: "sha256:aa", Users: 5, Key: busKey.PublicKey}}) + p := partOn(b) + p.Objects = &fakeObjects{} + var said []asks.Condition + p.Say = func(c asks.Condition) error { + if c.Key == grants.HeldModuleCondition { + said = append(said, c) + } + return nil + } + p.Compose() + inForce, _, _ := p.Sealed() + before, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "b.mod-r.mod-r-tools")) + if !ok || len(said) != 1 || said[0].State != asks.StateCleared { + t.Fatalf("the list in force keeps no grants of b.mod-r.mod-r-tools (%v), or a held module was said before: %+v", ok, said) + } + for gen, held := range []struct{ name, unknown string }{{"waits", "core-credentials"}, {"refused", ""}} { + // Another user's hash changes in the same pass, so a list that is written shows the held module did not hold it. + b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": fmt.Sprintf("$2a$10$gen%d", gen), "generation": gen + 2, "fingerprint": fmt.Sprintf("sha256:%d", gen+2)}) + was, _, _ := p.Sealed() + var unknown []string + if held.unknown != "" { + unknown = []string{held.unknown} + } + b.put(grants.ReadModules, "mod-r", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), + Manifest: delivery.ManifestDigest([]byte(ctl)), Repository: "novox/x", Commit: "c", Verdict: delivery.VerdictRefused, + Problems: []string{"it is held"}, Unknown: unknown}) + b.put(grants.ReadModules, "mod-q", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), + Manifest: delivery.ManifestDigest([]byte(ctl)), Repository: "novox/x", Commit: "c"}) + res := p.Compose() + now, _, _ := p.Sealed() + after, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "b.mod-r.mod-r-tools")) + if now.Digest == was.Digest || slices.ContainsFunc(res.Errors, func(e string) bool { return strings.Contains(e, "composed whole") }) { + t.Errorf("%s: a held module held the whole list: sealed %+v", held.name, res.Sealed) + } + if !ok || string(after) != string(before) || len(res.PrincipalsRefused) != 0 { + t.Errorf("%s: b.mod-r.mod-r-tools did not keep its grants of the list in force: %s, was %s", held.name, after, before) + } + if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "b.mod-r.mod-r-tools (stays in the list") { + t.Errorf("%s: the kept user was not said: %+v", held.name, last) + } + } + // Accepted again: the users compose from the manifest and the condition clears. + b.manifestOf("mod-r", ctl) + if p.Compose(); said[len(said)-1].State != asks.StateCleared { + t.Errorf("the condition was not cleared once the manifest is accepted: %+v", said[len(said)-1]) + } + }) } // fakeObjects is the part's object bucket of sealed lists. diff --git a/modules/mesh-grants/internal/grants/held.go b/modules/mesh-grants/internal/grants/held.go new file mode 100644 index 00000000..1b970744 --- /dev/null +++ b/modules/mesh-grants/internal/grants/held.go @@ -0,0 +1,120 @@ +package grants + +import ( + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// **A module whose manifest waits or is refused keeps its bus users** (clause 3 of the record on a release's Approve; issue +// 774). The indexer holds a manifest it refuses, or that waits for a judge that knows a field of it, out of the manifests +// the part composes from (inputs.go, Refused), so a module with no want naming a build had no manifest to derive its users +// from and its users left the list: the bus module's accounts among them, which locked its tools out. Now such a module's +// users, the ones the list in force carries, stay in the list as it holds them, the same way a refused principal's do +// (refused.go), and the part says so as the warning mesh-grants.module-held until the manifest is accepted. One module's +// manifest never holds the list: every other principal is composed as ever. A module whose want names a build is composed +// from the manifest of that build (Inputs.Running), not kept. + +// HeldModuleCondition is the key of the warning raised while a module's manifest is held and its users are kept. +const HeldModuleCondition = "mesh-grants.module-held" + +// heldModuleUsers are, by user, why it is kept: the users of candidates (the ones the list in force and the bus carry) that +// belong to a module placed on a node whose manifest the indexer holds (in.Refused) and whose want names no build. A user of +// a module on a node is `.` or `..` (buscompose.Principal.Username). Pure. +func heldModuleUsers(in Inputs, candidates []string) map[string]string { + held := map[string]string{} + for _, g := range in.Refused { + if module, ok := strings.CutPrefix(g.Input, "manifest of "); ok { + held[module] = g.Why + } + } + out := map[string]string{} + if len(held) == 0 { + return out + } + for node, modules := range in.Assigned { + for _, module := range modules { + why, ok := held[module] + if !ok { + continue + } + if _, runs := in.Running[node+"."+module]; runs { + continue + } + prefix := node + "." + module + for _, user := range candidates { + if user == prefix || strings.HasPrefix(user, prefix+".") { + out[user] = fmt.Sprintf("the manifest of %s is held: %s", module, why) + } + } + } + } + return out +} + +// inForceGrants are the grants of each user of the list in force as the part sealed them, or nil where it kept none. +func (p *Part) inForceGrants() map[string]User { + if g, _, had := p.Sealed(); had && g.Digest != "" && p.Store != nil { + inForce, _ := p.sealedGrantsOf(g.Digest) + return inForce + } + return nil +} + +// mergedRefusals is refused and held in one map, for the parts that keep a user's grants of the list in force. +func mergedRefusals(refused, held map[string]string) map[string]string { + if len(held) == 0 { + return refused + } + out := make(map[string]string, len(refused)+len(held)) + for u, why := range held { + out[u] = why + } + for u, why := range refused { + out[u] = why + } + return out +} + +// sayHeldModules raises HeldModuleCondition while a held module's users are kept, naming each user with what became of it, +// and clears it once none is; said once per set, again when it changes, and at the first pass after a start. +func (p *Part) sayHeldModules(held map[string]string, kept map[string]User) { + if p.Say == nil { + return + } + var lines []string + users := make([]string, 0, len(held)) + for u := range held { + users = append(users, u) + } + sort.Strings(users) + for _, user := range users { + became := "not in the list in force and left out" + if _, ok := kept[user]; ok { + became = "stays in the list as the list in force holds it" + } + lines = append(lines, user+" ("+became+"): "+held[user]) + } + why := strings.Join(lines, "; ") + p.holdMu.Lock() + defer p.holdMu.Unlock() + if p.heldModulesKnown && why == p.heldModulesSaid { + return + } + c := asks.Condition{Key: HeldModuleCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}} + if why == "" { + c.State = asks.StateCleared + } else { + c.Severity, c.State = asks.SeverityWarning, asks.StateRaised + c.Summary = fmt.Sprintf("%d bus user(s) of a module whose manifest is held are kept in the list: %s", len(users), why) + c.Headline, c.Needs = "A module's manifest is held, its bus users are kept", "a manifest the indexer accepts, or a judge that knows its fields" + c.Explanation = asks.NeedsYou + "a manifest the indexer accepts (a refused one mended, or a waiting one judged by an indexer that knows its fields). " + + "The rest of the user list is written as ever; a held module's users stay as the list in force holds them: " + why + c.Resolved = "every module's manifest is accepted" + } + if p.Say(c) == nil { + p.heldModulesSaid, p.heldModulesKnown = why, true + } +} diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 035ffb21..64f36d08 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -353,8 +353,8 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { } var m catalogue.Manifest if err == nil { - // The indexer's refusal, carried in its value (ADR 0578 §3): held and named as a refused manifest. - if verdict := v.VerdictOf(module); verdict != nil && verdict.Refused() { + // The indexer's refusal, or its wait for a judge that knows a field, carried in its value (ADR 0578 §3): held and named as a refused manifest. + if verdict := v.VerdictOf(module); verdict != nil && verdict.Held() { err = errors.New(verdict.Why()) } } @@ -551,14 +551,23 @@ func sortGaps(g []Gap) { sort.SliceStable(g, func(i, j int) bool { return strings.Compare(g[i].Input, g[j].Input) < 0 }) } -// ReadFailures names every read this pass could not make and every manifest refused: what a list composed now may lack. -func (in Inputs) ReadFailures() []string { +// ReadFailed names every read this pass could not make: what a list composed now may lack. A manifest the indexer holds +// (refused, or waiting for a judge that knows a field of it) is not one: it holds its module's users in the list as the +// list in force has them (held.go), and never holds the list, so one manifest never freezes the bus's users (issue 774). +func (in Inputs) ReadFailed() []string { var out []string for _, g := range in.Gaps { if g.Failed { out = append(out, g.Input+": "+g.Why) } } + return out +} + +// ReadFailures is ReadFailed and every manifest refused or waiting: what the part retires nothing on (the accounts it +// keeps, the hearings it withholds). +func (in Inputs) ReadFailures() []string { + out := in.ReadFailed() for _, g := range in.Refused { out = append(out, g.Input+": "+g.Why) } diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 29b8ffe4..bb0a8b30 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -189,6 +189,9 @@ type Part struct { // refusedSaid is the refused principals last said as a condition, once refusedKnown (refused.go). refusedSaid string refusedKnown bool + // heldModulesSaid is the held modules' kept users last said as a condition, once heldModulesKnown (held.go). + heldModulesSaid string + heldModulesKnown bool // missingSaid is the modules without a grant last said as a condition, once missingKnown (grants.go). missingSaid string missingKnown bool @@ -259,14 +262,24 @@ func (p *Part) Compose() Result { res.Errors = append(res.Errors, "the shadow list could not be composed: "+err.Error()) } // A refused principal keeps its grants of the list in force, said once per change (refused.go). - kept, _ := p.keptOf(refused) + // And a module whose manifest is held (refused, or waiting for a judge that knows a field of it) and whose want names no + // build keeps its users of the list in force (held.go). + loaded, loadErr := LoadedUsers(p.Call) + inForce := p.inForceGrants() + candidates := names(loaded) + for user := range inForce { + candidates = append(candidates, user) + } + heldUsers := heldModuleUsers(in, candidates) + allRefused := mergedRefusals(refused, heldUsers) + kept, _ := p.keptOf(allRefused) for _, user := range sortedKeys(refused) { res.PrincipalsRefused = append(res.PrincipalsRefused, user+": "+refused[user]) } p.sayPrincipalsRefused(refused, kept) + p.sayHeldModules(heldUsers, kept) users = WithHashes(withKept(users, kept), in.Hashes) st.Digest, st.Users = Digest(users), len(users) - loaded, loadErr := LoadedUsers(p.Call) st.Compare = Compare(users, loaded, Placed(in)).Excusing(ExcusedBy(MultiHeld(listRecs))) if loadErr != nil { st.Compare.Unread = loadErr.Error() @@ -298,7 +311,7 @@ func (p *Part) Compose() Result { if p.Store != nil { p.writeWithheld(&res, in, recs) } - list, n, leftOut, dropped, err := ComposeList(listRecs, in.Hashes, refused, kept) + list, n, leftOut, dropped, err := ComposeList(listRecs, in.Hashes, allRefused, kept) // The grants the assigner reads (ADR 0547 §4, §5): one that adds a permission only once the bus loaded a list that // carries it (issue 774). if p.Store != nil { diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 9b83190f..376900e1 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -299,7 +299,7 @@ func listUsers(list []byte) []string { // they could not be read). Empty when it may. func (p *Part) holdsBack(in Inputs, leftOut, dropped, bus, lost []string, digest string) string { p.hashHold = nil - if f := in.ReadFailures(); len(f) > 0 { + if f := in.ReadFailed(); len(f) > 0 { return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")" } if bus == nil {