Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
236fe47e4a | ||
|
|
32bc4f3c48 | ||
|
|
ed1125b66d | ||
|
|
366bc750da | ||
|
|
04a56b7aff |
@@ -481,13 +481,19 @@ func (p *passer) staleAsks(ctx context.Context, trunk map[string]delivery.Module
|
||||
}
|
||||
for key := range p.w.read(delivery.AssignerAsksBucket) {
|
||||
if p.staleAsk(key, trunk) {
|
||||
// The condition is cleared first and the ask goes only once that was said: the ask is what brings this key back at the
|
||||
// next pass, so a clear that failed once is made again, with no state of its own (issue 1906, note 29).
|
||||
k, kerr := delivery.ParseAnyAskKey(key)
|
||||
held := kerr == nil && (k.Kind == delivery.AskKindHold || k.Kind == delivery.AskKindRestore)
|
||||
if held && !p.clearHeld(k.Module, k.FP) {
|
||||
continue
|
||||
}
|
||||
p.logf("the ask %s is about a fingerprint or composition the trunk has left behind and is taken away", key)
|
||||
p.remove(ctx, delivery.AssignerAsksBucket, key)
|
||||
// The held record stays: it is the memory that this fingerprint failed (ADR 0408 §4), and a revert returns the trunk to
|
||||
// exactly it. Only the condition, which would else ask for an answer that can no longer be given, is cleared, and it is
|
||||
// raised again if the trunk returns to the fingerprint.
|
||||
if k, err := delivery.ParseAnyAskKey(key); err == nil && (k.Kind == delivery.AskKindHold || k.Kind == delivery.AskKindRestore) {
|
||||
p.clearHeld(k.Module, k.FP)
|
||||
if held {
|
||||
delete(p.said, assign.HeldCondition(k.Module, k.FP, "", "", false, false).Key+".raised")
|
||||
}
|
||||
}
|
||||
@@ -566,16 +572,18 @@ func (p *passer) answeredWith(key, option string) bool {
|
||||
}
|
||||
|
||||
// clearHeld clears, once, the condition of a hold the operator released.
|
||||
func (p *passer) clearHeld(module, fp string) {
|
||||
// It says whether nothing is left to say: the clear was said now or before, or there is no router to say it to.
|
||||
func (p *passer) clearHeld(module, fp string) bool {
|
||||
c := assign.HeldCondition(module, fp, "", "", false, false)
|
||||
if p.say == nil || p.said[c.Key+".cleared"] {
|
||||
return
|
||||
return true
|
||||
}
|
||||
if err := p.say(c); err != nil {
|
||||
p.logf("the condition of the released %s could not be cleared, and is cleared at the next pass: %v", module, err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
p.said[c.Key+".cleared"] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// recordAsk writes, once, the ask about a move of a module of policy record that waits for the operator's yes.
|
||||
|
||||
@@ -606,6 +606,36 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a clear that failed to be said is said at the next pass, and the ask goes after it", func(t *testing.T) {
|
||||
asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}}
|
||||
p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now())
|
||||
var said []asks.Condition
|
||||
fail := true
|
||||
p.say = func(c asks.Condition) error {
|
||||
if fail {
|
||||
fail = false
|
||||
return errors.New("router unreachable")
|
||||
}
|
||||
said = append(said, c)
|
||||
return nil
|
||||
}
|
||||
a := assign.HoldAsk(holdOld, "web", fpOld, "novox", "w", 1, time.Now())
|
||||
v, err := delivery.EncodeAssignerAsk(holdOld, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asksKV.values[holdOld] = v
|
||||
p.w.put(delivery.AssignerAsksBucket, holdOld, v)
|
||||
p.staleAsks(context.Background(), trunkOf(fpNew))
|
||||
if _, there := asksKV.values[holdOld]; !there || len(said) != 0 {
|
||||
t.Fatalf("after a failed clear: ask left = %v, conditions said = %d; want the ask kept and nothing said", there, len(said))
|
||||
}
|
||||
p.staleAsks(context.Background(), trunkOf(fpNew))
|
||||
if _, there := asksKV.values[holdOld]; there || len(said) != 1 || said[0].State != asks.StateCleared {
|
||||
t.Errorf("at the next pass: ask left = %v, conditions said = %v; want the ask gone and the clear said", there, said)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a lift takes the restore asks of its fingerprint on every node", func(t *testing.T) {
|
||||
asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}}
|
||||
p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now())
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-checker
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025211220-e0d1de047585
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026095702-79649c3fa9f4
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025211220-e0d1de047585 h1:7QoFH5qjXRpx5M4uPdYsVJ5qKl2082gq8n0lFxhoNIg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025211220-e0d1de047585/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026095702-79649c3fa9f4 h1:05sVel/UOvz4/g0dxrDOeVUL7BGakVrxPBlqRq02ZhM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026095702-79649c3fa9f4/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=
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// failingResult is a level's test result in which the named tests failed, each in its own package.
|
||||
func failingResult(t *testing.T, tests int, failed ...Failure) json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(Tested{Level: delivery.LevelIntegration, Tests: tests, Elapsed: 2, Failed: failed, Passed: len(failed) == 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func judgeLevel(t *testing.T, tests ...json.RawMessage) (*delivery.Verdict, *state) {
|
||||
t.Helper()
|
||||
st := &state{j: &Examiner{}, c: &Case{}, levels: map[string]*acc{delivery.LevelUnit: {}, delivery.LevelIntegration: {}}, nodes: map[string]bool{}}
|
||||
v, _ := st.level(t.Context(), &target{pull: Pull{Repository: "novox/x"}}, delivery.LevelAnswer{Level: delivery.LevelIntegration, Tests: tests})
|
||||
return v, st
|
||||
}
|
||||
|
||||
// Issue 1921: two failing tests in two packages are two entries of the verdict's Failures and two lines of a try's
|
||||
// failed list, each with its own first line (its cause, issue 1901), whether the build module answered them in one test
|
||||
// result (one go test run over both packages) or in two (the changed packages first, then the rest); every result is
|
||||
// counted, the ones after a failure too; and Failed keeps the first one's words whole.
|
||||
func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) {
|
||||
a := Failure{Package: "x/a", Test: "TestA", Cause: "a_test.go:9: want 1, got 2", Output: " a_test.go:3: logged\n a_test.go:9: want 1, got 2\n--- FAIL: TestA (0.00s)"}
|
||||
b := Failure{Package: "x/b", Test: "TestB", Cause: "b_test.go:4: boom", Output: " b_test.go:4: boom\n--- FAIL: TestB (0.00s)"}
|
||||
for name, tests := range map[string][]json.RawMessage{
|
||||
"one result": {failingResult(t, 10, a, b)},
|
||||
"two results": {failingResult(t, 4, a), failingResult(t, 3), failingResult(t, 3, b)},
|
||||
} {
|
||||
v, st := judgeLevel(t, tests...)
|
||||
if v == nil {
|
||||
t.Fatalf("%s: the level passed", name)
|
||||
}
|
||||
want := []string{"integration: x/a.TestA: a_test.go:9: want 1, got 2", "integration: x/b.TestB: b_test.go:4: boom"}
|
||||
if got := failedLines(*v); strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("%s: failed lines\n%s\nwant\n%s", name, strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
if len(v.Failures) != 2 || v.Failed.Test != "integration: x/a.TestA" || !strings.Contains(v.Failed.Words, "a_test.go:3: logged") {
|
||||
t.Errorf("%s: verdict %+v", name, v)
|
||||
}
|
||||
if run := st.levels[delivery.LevelIntegration].run; run != 10 {
|
||||
t.Errorf("%s: %d tests counted, want all 10", name, run)
|
||||
}
|
||||
if _, err := delivery.EncodeVerdict(delivery.Verdict{Group: "g", Heads: "0123456789ab", State: v.State, Failed: v.Failed, Failures: v.Failures}); err != nil {
|
||||
t.Errorf("%s: the verdict is not written: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921: past delivery.MaxFailures the list names the first fifty and how many more, the status line says how many
|
||||
// failed, and each entry's line is cut to MaxFailureLine.
|
||||
func TestFailuresAreCappedWithHowManyMore(t *testing.T) {
|
||||
var fs []Failure
|
||||
for i := range 60 {
|
||||
fs = append(fs, Failure{Package: fmt.Sprintf("x/p%d", i), Test: "TestT", Cause: strings.Repeat("y", 500)})
|
||||
}
|
||||
v, _ := judgeLevel(t, failingResult(t, 60, fs...))
|
||||
if len(v.Failures) != delivery.MaxFailures+1 || v.Failures[delivery.MaxFailures].Test != "and 10 more" {
|
||||
t.Fatalf("%d entries, last %+v", len(v.Failures), v.Failures[len(v.Failures)-1])
|
||||
}
|
||||
if len(v.Failures[0].Words) > MaxFailureLine {
|
||||
t.Errorf("an entry's line is %d bytes", len(v.Failures[0].Words))
|
||||
}
|
||||
if n := failedCount(*v); n != 60 {
|
||||
t.Errorf("failedCount = %d, want 60", n)
|
||||
}
|
||||
cs := &Case{Pulls: []Pull{{Repository: "novox/x", Head: "0123456789abcdef", Trunk: "fedcba9876543210"}}}
|
||||
if line := Line(*v, cs, cs.Pulls[0], nil, nil, nil); !strings.HasPrefix(line, "failed (60 tests): integration: x/p0.TestT") {
|
||||
t.Errorf("line %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// A verdict that names no list (a conflict, a refused step, a verdict written before issue 1921) is one failed line, as
|
||||
// before.
|
||||
func TestAVerdictWithoutFailuresIsOneLine(t *testing.T) {
|
||||
v := delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Machine: "g14", Words: "files in conflict: go.mod\nmore"}}
|
||||
if got := failedLines(v); len(got) != 1 || got[0] != "g14: files in conflict: go.mod" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,9 @@ func TestANodePackageIsNeverKeptAndRunsEveryCheck(t *testing.T) { // 1288/AC4, 1
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheChangesOwnFailingTestRunsFirstAndNothingElseRuns(t *testing.T) { // 1289/AC1, AC2
|
||||
// 1289/AC1, AC2 as issue 1921 changed it: the change's own failing test runs first, the level's other packages still run
|
||||
// after it (so one check names every failure), and no later level runs.
|
||||
func TestTheChangesOwnFailingTestRunsFirstThenTheRestAndNoLaterLevel(t *testing.T) {
|
||||
b := newBench(t)
|
||||
b.fetch.Changed = []string{"modules/m/internal/b/b_test.go"}
|
||||
b.digest[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"m/internal/a": digA, "m/internal/b": digB}}
|
||||
@@ -357,8 +359,8 @@ func TestTheChangesOwnFailingTestRunsFirstAndNothingElseRuns(t *testing.T) { //
|
||||
t.Fatalf("verdict = %+v", out.Verdict)
|
||||
}
|
||||
tests := b.steps.tool("test")
|
||||
if len(tests) != 1 || strings.Join(packagesOf(tests[0]), ",") != "m/internal/b" {
|
||||
t.Fatalf("test calls = %+v, want only m/internal/b, once", tests)
|
||||
if len(tests) != 2 || strings.Join(packagesOf(tests[0]), ",") != "m/internal/b" || strings.Join(packagesOf(tests[1]), ",") != "m/internal/a" {
|
||||
t.Fatalf("test calls = %+v, want m/internal/b, then m/internal/a, each once", tests)
|
||||
}
|
||||
if n := len(b.steps.tool("digest")); n != 1 {
|
||||
t.Errorf("%d digest calls, want the unit level's alone (the check stopped)", n)
|
||||
|
||||
@@ -438,8 +438,9 @@ func (st *state) ask(ctx context.Context, t *target, merged *[]Fetched) *deliver
|
||||
}
|
||||
|
||||
// level judges one level of a check ask's answer as the tools' answers were judged: the digest keys the kept verdicts
|
||||
// of the packages that passed, the kept ones taken are counted, and the first test result that did not pass is the
|
||||
// verdict. It answers the level's running time (its tests' and setup's).
|
||||
// of the packages that passed, the kept ones taken are counted, and every test result is read and counted, also after
|
||||
// one that did not pass, so the verdict names every failed test of the level (issue 1921). It answers the level's
|
||||
// running time (its tests' and setup's).
|
||||
func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer) (*delivery.Verdict, float64) {
|
||||
level := la.Level
|
||||
a := st.levels[level]
|
||||
@@ -461,6 +462,7 @@ func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer)
|
||||
a.kept += k.Tests
|
||||
}
|
||||
elapsed := 0.0
|
||||
var failedResults []Tested
|
||||
for _, raw := range la.Tests {
|
||||
var res Tested
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
@@ -489,9 +491,12 @@ func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer)
|
||||
}
|
||||
}
|
||||
if !res.Passed {
|
||||
return levelFailure(t, level, res), elapsed
|
||||
failedResults = append(failedResults, res)
|
||||
}
|
||||
}
|
||||
if len(failedResults) > 0 {
|
||||
return levelFailure(t, level, failedResults...), elapsed
|
||||
}
|
||||
return nil, elapsed
|
||||
}
|
||||
|
||||
@@ -633,23 +638,60 @@ func firstLine(s string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// failure turns a level that did not pass into a verdict naming what failed first.
|
||||
func levelFailure(t *target, level string, res Tested) *delivery.Verdict {
|
||||
// MaxFailureLine is the most bytes of a failure's line in a verdict's Failures: the first line of its words, which is
|
||||
// its cause where the build module told one apart (issue 1901). Failed keeps the first failure's words whole.
|
||||
const MaxFailureLine = 300
|
||||
|
||||
// levelFailure turns a level that did not pass into a verdict: Failed names what failed first with its words whole,
|
||||
// and Failures every failed test of the level's results with its first line, at most delivery.MaxFailures and an entry
|
||||
// saying how many more (issue 1921).
|
||||
func levelFailure(t *target, level string, results ...Tested) *delivery.Verdict {
|
||||
v := delivery.Verdict{State: delivery.VerdictFail}
|
||||
switch {
|
||||
case len(res.Failed) > 0:
|
||||
f := res.Failed[0]
|
||||
name := failedName(f)
|
||||
if t.module == buildrun.NodeBuildModule && f.Package == "" {
|
||||
name = res.Module
|
||||
var all []delivery.FailedFirst
|
||||
for _, res := range results {
|
||||
if len(res.Failed) == 0 {
|
||||
why := res.Why
|
||||
if why == "" {
|
||||
why = "the " + level + " level did not pass"
|
||||
}
|
||||
all = append(all, delivery.FailedFirst{Test: level + ": " + t.pull.Repository, Words: why})
|
||||
continue
|
||||
}
|
||||
v.Failed = delivery.FailedFirst{Test: level + ": " + name, Words: failureWords(f)}
|
||||
default:
|
||||
why := res.Why
|
||||
if why == "" {
|
||||
why = "the " + level + " level did not pass"
|
||||
for _, f := range res.Failed {
|
||||
name := failedName(f)
|
||||
if t.module == buildrun.NodeBuildModule && f.Package == "" {
|
||||
name = res.Module
|
||||
}
|
||||
all = append(all, delivery.FailedFirst{Test: level + ": " + name, Words: failureWords(f)})
|
||||
}
|
||||
v.Failed = delivery.FailedFirst{Test: level + ": " + t.pull.Repository, Words: why}
|
||||
}
|
||||
if len(all) == 0 { // no result: a level said failed with nothing to name
|
||||
all = append(all, delivery.FailedFirst{Test: level + ": " + t.pull.Repository, Words: "the " + level + " level did not pass"})
|
||||
}
|
||||
v.Failed = all[0]
|
||||
v.Failures = failureLines(all)
|
||||
return &v
|
||||
}
|
||||
|
||||
// failureLines is every failure with the first line of its words, cut to MaxFailureLine, at most delivery.MaxFailures
|
||||
// and then one entry saying how many more failed.
|
||||
func failureLines(all []delivery.FailedFirst) []delivery.FailedFirst {
|
||||
n := min(len(all), delivery.MaxFailures)
|
||||
out := make([]delivery.FailedFirst, 0, n+1)
|
||||
for _, f := range all[:n] {
|
||||
out = append(out, delivery.FailedFirst{Test: f.Test, Machine: f.Machine, Words: clipLine(firstLine(f.Words), MaxFailureLine)})
|
||||
}
|
||||
if more := len(all) - n; more > 0 {
|
||||
out = append(out, delivery.FailedFirst{Test: fmt.Sprintf("and %d more", more)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// clipLine cuts a line to at most n bytes on a whole character, marking the cut.
|
||||
func clipLine(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
s = strings.ToValidUTF8(s[:n-3], "")
|
||||
return s + "..."
|
||||
}
|
||||
|
||||
@@ -49,6 +49,34 @@ func firstFailed(v delivery.Verdict) string {
|
||||
return firstWordsLine(f.Words)
|
||||
}
|
||||
|
||||
// failedLines is everything a verdict names as failed, one line each: every failed test with its first line (issue
|
||||
// 1921), or what failed first when the verdict names no list (a conflict, a refused step, an older verdict).
|
||||
func failedLines(v delivery.Verdict) []string {
|
||||
if len(v.Failures) == 0 {
|
||||
if f := firstFailed(v); f != "" {
|
||||
return []string{f}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lines := make([]string, 0, len(v.Failures))
|
||||
for _, f := range v.Failures {
|
||||
lines = append(lines, firstFailed(delivery.Verdict{Failed: f}))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// failedCount is how many tests a verdict names as failed, the ones after delivery.MaxFailures counted too.
|
||||
func failedCount(v delivery.Verdict) int {
|
||||
n := len(v.Failures)
|
||||
if n > delivery.MaxFailures {
|
||||
var more int
|
||||
if _, err := fmt.Sscanf(v.Failures[n-1].Test, "and %d more", &more); err == nil {
|
||||
return delivery.MaxFailures + more
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// firstWordsLine is the first line of a failure's words: the words are several lines (issue 1901) and the status line one.
|
||||
func firstWordsLine(w string) string { return firstLine(w) }
|
||||
|
||||
@@ -155,6 +183,9 @@ func parts(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []s
|
||||
pc.head = "pass " + commits
|
||||
default:
|
||||
pc.head = "failed: " + firstFailed(v)
|
||||
if n := failedCount(v); n > 1 {
|
||||
pc.head = fmt.Sprintf("failed (%d tests): %s", n, firstFailed(v))
|
||||
}
|
||||
pc.commits = commits
|
||||
}
|
||||
var counts []string
|
||||
|
||||
@@ -69,7 +69,9 @@ type Try struct {
|
||||
// Said is the try's report in words: what failed first, the tests run, kept and skipped per level, the time of each part.
|
||||
Said string `json:"said,omitempty"`
|
||||
// Took is the time from its start to its end, as Go writes a duration.
|
||||
Took string `json:"took,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
// Failed is every test that failed, each with its first line, at most delivery.MaxFailures and then how many more
|
||||
// (issue 1921); or what else failed first (a conflict, a refused step).
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
Levels map[string]delivery.LevelResult `json:"levels,omitempty"`
|
||||
Machines []string `json:"machines,omitempty"`
|
||||
@@ -359,9 +361,7 @@ func (tr *Tryer) run(ctx context.Context, key string) {
|
||||
t.State = TryPass
|
||||
default:
|
||||
t.State = TryFail
|
||||
if f := firstFailed(v); f != "" {
|
||||
t.Failed = []string{f}
|
||||
}
|
||||
t.Failed = failedLines(v)
|
||||
}
|
||||
if t.Tests != "" {
|
||||
t.Said += " | only the tests " + t.Tests + " (Go); nothing kept"
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The three bus adapters for mesh-sdk go/asker on the part's own credential, as mesh-delivery-teller has them (copied: the
|
||||
// SDK's asker reaches the bus through a bundle's runtime; moving the adapters into the SDK is a change of its own). What
|
||||
// the account may do is the controller's composed grant for a module that uses the seat operator-channel: publish its own
|
||||
// ask and condition, hear its own decided event, and read the router's record of its own asks.
|
||||
|
||||
const recordWithin = 30 * time.Second
|
||||
|
||||
func publishVia(ctx context.Context, js jetstream.JetStream) func(subject string, body any, id string) error {
|
||||
return func(subject string, body any, id string) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var opts []jetstream.PublishOpt
|
||||
if id != "" {
|
||||
opts = append(opts, jetstream.WithMsgID(id))
|
||||
}
|
||||
_, err = js.Publish(ctx, subject, data, opts...)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func recordVia(ctx context.Context, conn *nats.Conn, module string) func(id string) (*stdio.StateEntry, error) {
|
||||
return func(id string) (*stdio.StateEntry, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, recordWithin)
|
||||
defer cancel()
|
||||
msg, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_asks.$KV.asks."+module+"."+id, nil)
|
||||
if errors.Is(err, nats.ErrNoResponders) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status := msg.Header.Get("Status"); status == "404" || len(msg.Data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &stdio.StateEntry{Key: id, Value: msg.Data}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func decidedVia(conn *nats.Conn, module string, logf func(string, ...any)) func(pattern string, handle func(stdio.Envelope) error) error {
|
||||
return func(pattern string, handle func(stdio.Envelope) error) error {
|
||||
_, err := conn.Subscribe(asks.DecidedSubject(module), func(m *nats.Msg) {
|
||||
if err := handle(stdio.Envelope{Key: pattern, Body: m.Data}); err != nil {
|
||||
logf("a warrant could not be handled and is read again from the router's record at the next start: %v", err)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func sayVia(ctx context.Context, js jetstream.JetStream, module string) func(c asks.Condition) error {
|
||||
return func(c asks.Condition) error {
|
||||
subject, err := asks.ConditionSubject(module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := asks.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = js.Publish(ctx, subject, data)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
// created** (the operator, 2026-10-26 08:54; issue 1790), once per manifest, with the manifest's limits, on the event
|
||||
// that brought the manifest; one the bus holds is never changed. **The user list, the rest of the assertions and the
|
||||
// grants stay in shadow** until
|
||||
// the operator's take-over, asked on their channel and run on their yes after its refusals: they are composed, compared with what the bus loaded
|
||||
// the operator's take-over, which this milestone always refuses: they are composed, compared with what the bus loaded
|
||||
// and holds (through the bus module's own read tools) and kept in the part's own state, and take no effect.
|
||||
//
|
||||
// It holds no timer, and never exits for a refused read, write or publish: a refusal is said and tried again at the next
|
||||
@@ -39,7 +39,6 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-grants/internal/grants"
|
||||
@@ -118,15 +117,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn},
|
||||
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js}}
|
||||
f.Watch = bus
|
||||
// The operator's word on take-over and hand-back is their yes on their channel: the part asks it itself, and acts on
|
||||
// the warrant once (mesh-sdk asker), after its refusals.
|
||||
client := &asker.Client{Module: account.Module, State: conn.State(grants.StateTakeover), Publish: publishVia(ctx, js),
|
||||
Record: recordVia(ctx, nc, account.Module), Subscribe: decidedVia(nc, account.Module, logger.Printf)}
|
||||
f.Part.Asker, f.Part.Say = client, sayVia(ctx, js, account.Module)
|
||||
f.Part.Forget = func(id string) error { return conn.State(grants.StateTakeover).Delete("ask." + id) }
|
||||
if err := client.Listen(f.Part.Warranted, f.Part.Said); err != nil {
|
||||
logger.Printf("the operator's answers cannot be heard yet, so take-over and hand-back wait: %v", err)
|
||||
}
|
||||
f.Asked = func(wake func()) error {
|
||||
return conn.State(grants.StateUserList).Watch(grants.KeyAsked, func(c stdio.StateChange) error {
|
||||
if !c.Current {
|
||||
@@ -135,20 +125,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// The controller makes this module's durable consumer on EVENTS from the manifest's consumes (filtered on the two
|
||||
// condition subjects); it is pulled and every message acknowledged, so its pending never grows. Where it is not there
|
||||
// yet the pass says so and hears again at the next change.
|
||||
f.Hear = func(handle func(string, []byte)) error {
|
||||
cons, err := js.Consumer(ctx, "EVENTS", account.Node+"_"+account.Module)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the consumer %s_%s on EVENTS: %w", account.Node, account.Module, err)
|
||||
}
|
||||
_, err = cons.Consume(func(m jetstream.Msg) {
|
||||
handle(m.Subject(), m.Data())
|
||||
_ = m.Ack()
|
||||
}, jetstream.ConsumeErrHandler(func(_ jetstream.ConsumeContext, err error) { logger.Printf("hearing the bus: %v", err) }))
|
||||
return err
|
||||
}
|
||||
logger.Printf("started on the account %s: publishing memberships and creating the declared buckets, logs and streams the bus lacks; the user list, grants and any change to a held stream in shadow", account.User)
|
||||
f.Run(ctx)
|
||||
return nil
|
||||
|
||||
@@ -97,25 +97,20 @@ func toolsOf(p *grants.Part) []stdio.Tool {
|
||||
"changed, a difference is only said. Answers the comparison and the ask; call again to see what the bus holds.",
|
||||
Input: schema(map[string]string{"module": "the module"}, "module"),
|
||||
Run: func(a map[string]any) (any, error) { return p.AssertOne(grants.Answer(a, "module")) }},
|
||||
{Name: grants.ToolTakeOver, Description: "Ask the operator to switch the bus node to the grants part's list (ADR 0547 clause 8). " +
|
||||
"The operator's word is their yes on their channel (a warrant), never a caller's name: this tool only asks, once at a time. " +
|
||||
"After the yes, the switch is recorded and takes effect when the bus node loads the part's list; it is refused while the loaded list could not be read, a user differs between the lists " +
|
||||
"(an enrolment user or a person the part cannot compose among them), or the vault holds no hash for a user, and the " +
|
||||
"refusal is said to the operator naming the users. Every act is recorded in the part's state takeover.",
|
||||
{Name: grants.ToolTakeOver, Description: "The operator's switch to the grants part's list (ADR 0547 clause 8), refused without the " +
|
||||
"operator's credential (a person's account), refused while any user of the shadow list differs from the loaded list (but " +
|
||||
"those placement no longer names), naming them; in this milestone always refused, since the enrolment users and the " +
|
||||
"persons cannot be composed. Every ask is recorded in the part's state takeover.",
|
||||
Input: schema(map[string]string{"why": "why, kept with the act"}, "why"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.TakeOver(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
return nil, p.TakeOver(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
}},
|
||||
{Name: grants.ToolHandBack, Description: "Ask the operator to switch the bus node back to the controller's list (ADR 0547 clause 8), " +
|
||||
"by their yes on their channel; in shadow there is nothing to hand back, and it says so. Every act is recorded.",
|
||||
{Name: grants.ToolHandBack, Description: "The operator's switch back to the controller's list (ADR 0547 clause 8), refused without " +
|
||||
"the operator's credential; in shadow there is nothing to hand back, and it says so. Every ask is recorded.",
|
||||
Input: schema(map[string]string{"why": "why, kept with the act"}, "why"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
return nil, p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
}},
|
||||
{Name: grants.ToolTakeOverState, Description: "Where the switch stands: the list the bus node follows, the ask open now, the request, and " +
|
||||
"every act with what became of it (asked, switched, declined, expired, refused and why).",
|
||||
Input: schema(nil),
|
||||
Run: func(map[string]any) (any, error) { return p.TakeOverState() }},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,6 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
@@ -51,8 +48,6 @@ type fakeBus struct {
|
||||
// creates are the streams the part asked to create, refused when refuseCreate.
|
||||
creates []string
|
||||
refuseCreate bool
|
||||
// rootFree answers the controller's root-free verb: nil is no answerer at all (no responders).
|
||||
rootFree func(machines []string) (any, error)
|
||||
}
|
||||
|
||||
func (b *fakeBus) LastMembership(node, module string) ([]byte, uint64, bool, error) {
|
||||
@@ -171,15 +166,6 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"users": out})
|
||||
case grants.CallStreams:
|
||||
return json.Marshal(map[string]any{"detail": b.streams})
|
||||
case grants.CallRootFree:
|
||||
if b.rootFree == nil {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
out, err := b.rootFree(args["machines"].([]string))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
return nil, fmt.Errorf("no tool %s", key)
|
||||
}
|
||||
@@ -314,7 +300,6 @@ func TestGrants(t *testing.T) {
|
||||
}{
|
||||
{"reads", m.Reads, grants.Reads},
|
||||
{"invokes", m.Invokes, grants.Invokes},
|
||||
{"consumes", m.Consumes, grants.Consumes},
|
||||
{"emits", m.Emits, grants.Emits},
|
||||
{"tools", m.Tools, grants.Tools},
|
||||
} {
|
||||
@@ -342,8 +327,6 @@ func TestGrants(t *testing.T) {
|
||||
// in create.go (issue 1790).
|
||||
publishOnly := filepath.Join("cmd", "mesh-grants", "publish.go")
|
||||
createOnly := filepath.Join("cmd", "mesh-grants", "create.go")
|
||||
// And the operator's asks and conditions, on the seat operator-channel's subjects, in asking.go (ADR 0547 clause 8).
|
||||
asksOnly := filepath.Join("cmd", "mesh-grants", "asking.go")
|
||||
files := 0
|
||||
for _, root := range []string{"cmd", "internal"} {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
@@ -352,11 +335,8 @@ func TestGrants(t *testing.T) {
|
||||
}
|
||||
files++
|
||||
src, err := os.ReadFile(path)
|
||||
if path != publishOnly && path != asksOnly && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) {
|
||||
t.Errorf("%s publishes: only publish.go publishes memberships, and asking.go the operator's asks", path)
|
||||
}
|
||||
if path == asksOnly && (strings.Count(string(src), ".Publish(") != 2 || strings.Contains(string(src), "mesh.assignment")) {
|
||||
t.Errorf("asking.go publishes other than an ask, a cancel and a condition, through js.Publish twice")
|
||||
if path != publishOnly && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) {
|
||||
t.Errorf("%s publishes: only publish.go publishes, and only memberships", path)
|
||||
}
|
||||
if path == publishOnly && strings.Count(string(src), ".Publish(") != 1 {
|
||||
t.Errorf("publish.go publishes other than one membership")
|
||||
@@ -456,94 +436,6 @@ func TestGrants(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("root-free: the controller's word fills the composition, failing closed (issue 1945)", func(t *testing.T) {
|
||||
const router = `{"module":"messenger","version":"1","runs-as":"messenger","resources":[{"id":"account","type":"user","name":"messenger","shell":"/usr/bin/nologin","home":"/var/lib/messenger"}],"own-secrets":{"broker":"/var/lib/m/broker"},"secrets-owner":"messenger",` +
|
||||
`"seats":[{"name":"operator-channel","scope":"mesh","serves":["open"]},{"name":"channel","scope":"mesh","kinded":true,"accepts":["show"]}],` +
|
||||
`"claims":[{"name":"operator-channel","scope":"mesh","serves":["open"]}]}`
|
||||
const telegram = `{"module":"telegram","version":"1","runs-as":"telegram","resources":[{"id":"account","type":"user","name":"telegram","shell":"/usr/bin/nologin","home":"/var/lib/telegram"}],"own-secrets":{"broker":"/var/lib/t/broker"},"secrets-owner":"telegram",` +
|
||||
`"claims":[{"name":"channel","scope":"mesh","kind":"telegram","capabilities":["deliver","verified-sender"]}]}`
|
||||
answer := func(free ...string) func([]string) (any, error) {
|
||||
return func(machines []string) (any, error) {
|
||||
var vs []map[string]any
|
||||
for _, m := range machines {
|
||||
ok := slices.Contains(free, m)
|
||||
why := "judged free"
|
||||
if !ok {
|
||||
why = "its node-engine judged nothing"
|
||||
}
|
||||
vs = append(vs, map[string]any{"machine": m, "free": ok, "why": why})
|
||||
}
|
||||
return map[string]any{"machines": vs}, nil
|
||||
}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
routerOn []string
|
||||
telegramOn string
|
||||
ask func([]string) (any, error)
|
||||
keeps bool
|
||||
note string
|
||||
}{
|
||||
{"router and telegram on one free machine", []string{"a"}, "a", answer("a"), true, ""},
|
||||
{"router and telegram on two free machines", []string{"a"}, "b", answer("a", "b"), true, ""},
|
||||
{"the holder's machine is not free", []string{"a"}, "b", answer("a"), false, "b: not free, its node-engine judged nothing"},
|
||||
{"the router's machine is not free", []string{"a"}, "b", answer("b"), false, "a: not free"},
|
||||
{"an error is not free", []string{"a"}, "a", func([]string) (any, error) { return nil, errors.New("boom") }, false, "boom"},
|
||||
{"no answerer is not free", []string{"a"}, "a", nil, false, "no responders"},
|
||||
{"a machine left out of the answer is not free", []string{"a"}, "a",
|
||||
func([]string) (any, error) { return map[string]any{"machines": []any{}}, nil }, false, "named no verdict"},
|
||||
{"the router on two machines frees nothing", []string{"a", "b"}, "a", answer("a", "b"), false, ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
b := fixture()
|
||||
b.manifestOf("messenger", router)
|
||||
b.manifestOf("telegram", telegram)
|
||||
for _, node := range row.routerOn {
|
||||
b.assign(node, "messenger")
|
||||
}
|
||||
b.assign(row.telegramOn, "telegram")
|
||||
b.rootFree = row.ask
|
||||
res := partOn(b).Compose()
|
||||
recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{}))
|
||||
grants.WithRootFree(&recs, b)
|
||||
kept := false
|
||||
for _, k := range buscompose.PlacementsOf(recs, recs.Interchangeable).Kinds {
|
||||
kept = kept || slices.Contains(k.Capabilities, "verified-sender")
|
||||
}
|
||||
if kept != row.keeps {
|
||||
t.Errorf("verified-sender kept = %v, want %v (said %v)", kept, row.keeps, res.RootNotFree)
|
||||
}
|
||||
if !strings.Contains(strings.Join(res.RootNotFree, " | "), row.note) || (row.note == "" && row.keeps && len(res.RootNotFree) != 0) {
|
||||
t.Errorf("the pass says %v, want it to name %q", res.RootNotFree, row.note)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A root-not-free condition raised or cleared wakes a pass; another kind does not (never a timer).
|
||||
wb := fixture()
|
||||
var handle func(string, []byte)
|
||||
wf := &grants.Follower{Part: partOn(wb), Watch: &fakeWatcher{opened: make(chan string, 16)}, Logf: func(string, ...any) {},
|
||||
Hear: func(h func(string, []byte)) error { handle = h; return nil }}
|
||||
wctx, wcancel := context.WithCancel(context.Background())
|
||||
wdone := make(chan struct{})
|
||||
go func() { wf.Run(wctx); close(wdone) }()
|
||||
waitFor(t, func() bool { return wb.count(grants.CallUsers) > 0 && handle != nil })
|
||||
passes := func() int { return wb.count(grants.CallUsers) }
|
||||
before := passes()
|
||||
handle(grants.EventConditionRaised, []byte(`{"kind":"disk-full","key":"machine.a.disk-full"}`))
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if passes() != before {
|
||||
t.Errorf("a condition of another kind woke a pass")
|
||||
}
|
||||
handle(grants.EventConditionCleared, []byte(`{"kind":"root-not-free","key":"machine.a.root-not-free"}`))
|
||||
waitFor(t, func() bool { return passes() > before })
|
||||
wcancel()
|
||||
<-wdone
|
||||
if !grants.IsRootNotFree([]byte(`{"kind":"root-not-free"}`)) || !grants.IsRootNotFree([]byte(`{"key":"machine.novox.root-not-free"}`)) ||
|
||||
grants.IsRootNotFree([]byte(`{"kind":"disk-full","key":"machine.novox.disk-full"}`)) {
|
||||
t.Errorf("only a root-not-free condition wakes a pass")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a predefined-key fallback is read with no list; every refused manifest listed", func(t *testing.T) {
|
||||
// mod-p's value falls back to a predefined key, as claude-code's managed_settings does, and no list is on the bus
|
||||
// (issue 1945); mod-z's manifest carries a key mesh-sdk's reader refuses, and mod-z is assigned nowhere.
|
||||
@@ -554,20 +446,7 @@ func TestGrants(t *testing.T) {
|
||||
b.manifestOf("mod-p", pManifest)
|
||||
b.manifestOf("mod-z", zManifest)
|
||||
b.assign("b", "mod-p")
|
||||
// mesh-wireguard is provided by the mesh, not read from a repository: no manifest in the indexer, none refused,
|
||||
// no gap, and its manifest is the one mesh-sdk provides (issue 1945).
|
||||
b.assign("b", "mesh-wireguard")
|
||||
in := grants.ReadInputs(b, &grants.Manifests{})
|
||||
// An indexer row of the same name never wins: the provided manifest is used and the row is said as a gap.
|
||||
bw := fixture()
|
||||
bw.manifestOf("mesh-wireguard", `{"module":"mesh-wireguard","version":"9","own-secrets":{"broker":"/x"}}`)
|
||||
bw.assign("b", "mesh-wireguard")
|
||||
if iw := grants.ReadInputs(bw, &grants.Manifests{}); !strings.HasPrefix(iw.Digests["mesh-wireguard"], "provided:") || !gapNamed(iw.Gaps, "manifest of mesh-wireguard") || len(iw.Refused) != 0 {
|
||||
t.Errorf("an indexer manifest of mesh-wireguard won or went unsaid: %v %v", iw.Digests["mesh-wireguard"], iw.Gaps)
|
||||
}
|
||||
if _, ok := in.Manifests["mesh-wireguard"]; !ok || gapNamed(in.Gaps, "manifest of mesh-wireguard") || !strings.HasPrefix(in.Digests["mesh-wireguard"], "provided:") {
|
||||
t.Errorf("mesh-wireguard's manifest is not taken from mesh-sdk: gaps %v", in.Gaps)
|
||||
}
|
||||
if len(in.Refused) != 1 || in.Refused[0].Input != "manifest of mod-z" || !gapNamed(in.Gaps, "manifest of mod-z") {
|
||||
t.Errorf("refused = %v: only mod-z's, and named in the gaps", in.Refused)
|
||||
}
|
||||
@@ -649,9 +528,9 @@ func TestGrants(t *testing.T) {
|
||||
!slices.Contains(cr.LeftOut, "b.mod-y") {
|
||||
t.Errorf("a hash heard = %+v", after.UserList.Credentials)
|
||||
}
|
||||
why := strings.Join(p.Decide(), "; ")
|
||||
if !strings.Contains(why, "no hash for") || strings.Contains(why, "a.mod-x,") {
|
||||
t.Errorf("take-over is refused for %q, want the users with no hash named", why)
|
||||
err := p.TakeOver("person.operator", "hashes")
|
||||
if err == nil || !strings.Contains(err.Error(), "no hash for") || strings.Contains(err.Error(), "a.mod-x,") {
|
||||
t.Errorf("take-over = %v, want the users with no hash named", err)
|
||||
}
|
||||
for state, keys := range b.own {
|
||||
for k, v := range keys {
|
||||
@@ -699,32 +578,6 @@ func TestGrants(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subscriptions on its machines, nothing else", func(t *testing.T) {
|
||||
const retired, store, storeInfo = "mesh.seat.mesh-delivery.tool.mark-ready", "mesh.seat.mesh-store.tool.databases", "$SRV.INFO.mesh-store.>"
|
||||
two := map[string][]string{"mesh-store": {"a", "n"}}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
user string
|
||||
subShadow, subBus, pubShadow []string
|
||||
multi map[string][]string
|
||||
differs bool
|
||||
excused int
|
||||
}{
|
||||
{"a retired verb the shadow dropped", "a.node-tools", []string{"x"}, []string{"x", retired}, nil, nil, false, 1},
|
||||
{"a verb of a live seat the shadow lost", "a.node-tools", []string{"x"}, []string{"x", store}, nil, two, true, 0},
|
||||
{"a seat claimed on two machines, added to a user on one", "a.node-tools", []string{"x", store, storeInfo}, []string{"x"}, nil, two, false, 2},
|
||||
{"the same added to a user on a machine that does not claim it", "q.node-tools", []string{"x", store}, []string{"x"}, nil, two, true, 0},
|
||||
{"a publish of the seat added is not excused", "a.node-tools", []string{"x"}, []string{"x"}, []string{store}, two, true, 0},
|
||||
{"the same added for a seat claimed once", "a.node-tools", []string{"x", store}, []string{"x"}, nil, nil, true, 0},
|
||||
} {
|
||||
c := grants.Compare([]grants.User{{User: row.user, Subscribe: row.subShadow, Publish: row.pubShadow}}, []grants.User{{User: row.user, Subscribe: row.subBus}}, nil).
|
||||
Excusing(grants.ExcusedBy(row.multi))
|
||||
if c.Equal() == row.differs || len(c.Excused) != row.excused {
|
||||
t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools", func(t *testing.T) {
|
||||
b := fixture()
|
||||
b.loaded = shadowOf(t, b)
|
||||
@@ -789,165 +642,54 @@ func TestGrants(t *testing.T) {
|
||||
}
|
||||
|
||||
b.loaded = append(b.loaded, grants.User{User: "b.gone"})
|
||||
fa := &fakeAsker{wheres: map[string]*asks.Warrant{}}
|
||||
var forgot []string
|
||||
n := 0
|
||||
p.Nonce, p.Forget = func() string { n++; return fmt.Sprintf("%04d", n) }, func(id string) error { forgot = append(forgot, id); return nil }
|
||||
var said []asks.Condition
|
||||
p.Asker, p.Say = fa, func(c asks.Condition) error { said = append(said, c); return nil }
|
||||
warrant := func(id string) asks.Warrant { return asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen} }
|
||||
last := func() grants.Act {
|
||||
raw, _, _, _ := b.ownGet(grants.StateTakeover, grants.KeySwitch)
|
||||
var s grants.Switch
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
if s.Open != "" && s.Acts[len(s.Acts)-1].Said != "asked" {
|
||||
t.Errorf("an ask is open and its act is not asked: %+v", s)
|
||||
}
|
||||
return s.Acts[len(s.Acts)-1]
|
||||
}
|
||||
asked := func(act string) string {
|
||||
t.Helper()
|
||||
var res struct{ Asked string }
|
||||
var err error
|
||||
var out any
|
||||
if act == "take-over" {
|
||||
out, err = p.TakeOver("shanks.node-tools", "a row")
|
||||
} else {
|
||||
out, err = p.HandBack("shanks.node-tools", "a row")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", act, err)
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
return res.Asked
|
||||
}
|
||||
yes, no := asks.Option{ID: "yes"}, asks.Option{ID: "no"}
|
||||
for _, row := range []struct {
|
||||
name, act string
|
||||
option asks.Option
|
||||
setup func()
|
||||
said, never []string // the refusal's words and the users it must not name
|
||||
state string
|
||||
outcome string
|
||||
name, act, caller string
|
||||
says, never []string
|
||||
}{
|
||||
{"a yes while a user differs is refused and said, naming it", "take-over", yes, func() {}, []string{"users differ", "b.mod-x"}, []string{"b.gone"}, grants.Shadow, "refused"},
|
||||
{"a no leaves the switch", "take-over", no, func() {}, nil, nil, grants.Shadow, "declined"},
|
||||
{"a yes with the lists agreeing and every hash held records the switch", "take-over", yes, func() {
|
||||
b.states[grants.ReadAssignments] = nil
|
||||
b.assign("a", "nats")
|
||||
b.assign("a", "mod-x")
|
||||
b.assign("b", "mod-y")
|
||||
b.loaded = append(shadowOf(t, b), grants.User{User: "b.gone"})
|
||||
for _, u := range shadowOf(t, b) {
|
||||
b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$x", "generation": 1})
|
||||
}
|
||||
}, nil, nil, grants.TookOver, "recorded"},
|
||||
{"a yes to hand back records the switch back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "recorded"},
|
||||
{"take-over without the operator's credential", "take-over", "shanks.node-tools", []string{"operator's alone"}, nil},
|
||||
{"hand-back without the operator's credential", "hand-back", "unproven", []string{"operator's alone"}, nil},
|
||||
{"take-over while a user differs names it", "take-over", "person.operator", []string{"users differ", "b.mod-x", grants.NotComposable}, []string{"b.gone"}},
|
||||
{"hand-back in shadow", "hand-back", "person.operator", []string{"nothing to hand back"}, nil},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
row.setup()
|
||||
before := len(fa.asks)
|
||||
id := asked(row.act)
|
||||
if _, err := p.TakeOver("shanks.node-tools", "again"); err == nil || !strings.Contains(err.Error(), "already asked") {
|
||||
t.Errorf("a second ask while one is open = %v", err)
|
||||
var err error
|
||||
if row.act == "take-over" {
|
||||
err = p.TakeOver(row.caller, "a row")
|
||||
} else {
|
||||
err = p.HandBack(row.caller, "a row")
|
||||
}
|
||||
p.Compose()
|
||||
p.Compose()
|
||||
if len(fa.asks) != before+1 || fa.asks[before].ID != id {
|
||||
t.Fatalf("the operator was asked %d times, want once: %+v", len(fa.asks)-before, fa.asks)
|
||||
if err == nil {
|
||||
t.Fatalf("%s by %s was not refused", row.act, row.caller)
|
||||
}
|
||||
ask := fa.asks[before]
|
||||
if err := ask.Check(time.Date(2026, 10, 25, 22, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Errorf("the router would refuse the ask: %v", err)
|
||||
}
|
||||
if yesOpt, ok := ask.Option("yes"); !ok || yesOpt.Level != asks.Approve || !strings.HasPrefix(yesOpt.Binds, "sha256:") {
|
||||
t.Errorf("the yes does not approve and bind its act: %+v", yesOpt)
|
||||
}
|
||||
said = nil
|
||||
if err := p.Warranted(row.option, warrant(id)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := last()
|
||||
if l.Said != row.outcome || l.Ask != id || p.SwitchState() != row.state {
|
||||
t.Errorf("the act = %+v, the switch %s; want %s and %s", l, p.SwitchState(), row.outcome, row.state)
|
||||
}
|
||||
for _, w := range row.said {
|
||||
if !strings.Contains(l.Refused, w) || (row.outcome == "refused" && (len(said) != 1 || !strings.Contains(said[0].Explanation, w))) {
|
||||
t.Errorf("the refusal %q (said %v) does not say %q", l.Refused, said, w)
|
||||
for _, s := range row.says {
|
||||
if !strings.Contains(err.Error(), s) {
|
||||
t.Errorf("refusal %q does not say %q", err, s)
|
||||
}
|
||||
}
|
||||
for _, w := range row.never {
|
||||
if strings.Contains(l.Refused, w) {
|
||||
t.Errorf("the refusal names %q", w)
|
||||
for _, s := range row.never {
|
||||
if strings.Contains(err.Error(), s) {
|
||||
t.Errorf("refusal %q names %q", err, s)
|
||||
}
|
||||
}
|
||||
// A recorded switch says no refusal and clears the one a refused yes left raised.
|
||||
if row.outcome == "recorded" && row.act == "take-over" && (len(said) != 1 || said[0].State != asks.StateCleared || said[0].Key != "mesh-grants.take-over-refused") {
|
||||
t.Errorf("a recorded switch did not clear the refusal: %v", said)
|
||||
}
|
||||
if row.outcome == "recorded" && !strings.Contains(fmt.Sprint(p.TakeOverState()), "bus_node_loaded_grants_list:not yet") {
|
||||
t.Errorf("the state claims the bus node follows with no loaded list: %v", fmt.Sprint(p.TakeOverState()))
|
||||
raw, _, _, _ := b.ownGet(grants.StateTakeover, grants.KeySwitch)
|
||||
var s grants.Switch
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
last := s.Acts[len(s.Acts)-1]
|
||||
if s.State != grants.Shadow || last.Act != row.act || last.By != row.caller || last.Refused == "" {
|
||||
t.Errorf("the act is not recorded: %+v", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("hand-back in shadow is refused at once, with nothing asked", func(t *testing.T) {
|
||||
n := len(fa.asks)
|
||||
if _, err := p.HandBack("shanks.node-tools", "a row"); err == nil || !strings.Contains(err.Error(), "nothing to hand back") {
|
||||
t.Errorf("hand-back in shadow = %v", err)
|
||||
t.Run("take-over with only a user placement no longer names is refused for want of records alone", func(t *testing.T) {
|
||||
b.states[grants.ReadAssignments] = nil
|
||||
b.assign("a", "nats")
|
||||
b.assign("a", "mod-x")
|
||||
b.assign("b", "mod-y")
|
||||
b.loaded = append(shadowOf(t, b), grants.User{User: "b.gone"})
|
||||
err := p.TakeOver("person.operator", "lists agree")
|
||||
if err == nil || strings.Contains(err.Error(), "differ") || !strings.Contains(err.Error(), grants.NotComposable) {
|
||||
t.Errorf("take-over = %v, want refused for the enrolment users and persons alone", err)
|
||||
}
|
||||
p.Compose()
|
||||
if len(fa.asks) != n {
|
||||
t.Errorf("a refused hand-back asked the operator")
|
||||
}
|
||||
})
|
||||
t.Run("an ask that can stay open is closed every way and can be asked again", func(t *testing.T) {
|
||||
// A warrant the process missed is caught up from the router's record, whatever its outcome.
|
||||
id := asked("take-over")
|
||||
p.Compose()
|
||||
fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen, Option: "no"}
|
||||
p.Compose()
|
||||
if l := last(); l.Ask != id || l.Said != "declined" {
|
||||
t.Errorf("a missed warrant was not caught up: %+v", l)
|
||||
}
|
||||
// The router ended it with no choice, and the process was not told.
|
||||
id = asked("take-over")
|
||||
p.Compose()
|
||||
fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeCancelled}
|
||||
p.Compose()
|
||||
if l := last(); l.Ask != id || !strings.Contains(l.Said, "cancelled") {
|
||||
t.Errorf("an ask the router ended was not closed: %+v", l)
|
||||
}
|
||||
// Said closes on any outcome for the open id, and none for another id.
|
||||
id = asked("take-over")
|
||||
p.Said(asker.Refused, asks.Warrant{Ask: "other", Outcome: asks.OutcomeRefused}, nil)
|
||||
if _, err := p.TakeOver("x", "y"); err == nil {
|
||||
t.Errorf("a warrant for another ask closed the open one")
|
||||
}
|
||||
p.Said(asker.Refused, asks.Warrant{Ask: id, Outcome: asks.OutcomeRefused}, nil)
|
||||
// A warrant for an ask that is not the open one switches nothing.
|
||||
if err := p.Warranted(yes, warrant("take-over-other")); err == nil {
|
||||
t.Errorf("a warrant for an ask that is not open was acted on")
|
||||
}
|
||||
// Past its expiry and a margin the part closes it itself; the clock moves, not a timer.
|
||||
id = asked("take-over")
|
||||
now := p.Now
|
||||
p.Now = func() time.Time { return now().Add(25 * time.Hour) }
|
||||
p.Compose()
|
||||
p.Now = now
|
||||
if l := last(); l.Ask != id || l.Said != string(asks.OutcomeExpired) {
|
||||
t.Errorf("an expired ask was not closed: %+v", l)
|
||||
}
|
||||
// An ask that could not be published is closed as refused, its half-made record taken away, and is not retried.
|
||||
fa.fail = true
|
||||
id = asked("take-over")
|
||||
p.Compose()
|
||||
p.Compose()
|
||||
if l := last(); l.Ask != id || l.Said != "refused" || !slices.Contains(forgot, id) {
|
||||
t.Errorf("a failed ask stays open: %+v, forgot %v", l, forgot)
|
||||
}
|
||||
fa.fail = false
|
||||
asked("take-over")
|
||||
})
|
||||
|
||||
t.Run("only memberships published, nothing asserted", func(t *testing.T) {
|
||||
@@ -1258,30 +1000,6 @@ func TestGrants(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
type fakeAsker struct {
|
||||
asks []asks.Ask
|
||||
wheres map[string]*asks.Warrant // the router's warrants, by ask
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *fakeAsker) Ask(a asks.Ask) error {
|
||||
if f.fail {
|
||||
return errors.New("the bus refused the publish")
|
||||
}
|
||||
f.asks = append(f.asks, a)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAsker) Where(id string) (string, *asks.Warrant, error) { return "open", f.wheres[id], nil }
|
||||
|
||||
// Take hands the warrant to act as the SDK's asker does for one that chose, and records any other.
|
||||
func (f *fakeAsker) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error) {
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
return asker.Recorded, nil
|
||||
}
|
||||
return asker.Acted, act(asks.Option{ID: w.Option}, w)
|
||||
}
|
||||
|
||||
func nonNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
|
||||
@@ -28,20 +28,14 @@ type Follower struct {
|
||||
Logf func(string, ...any)
|
||||
// Asked watches the part's own ask to compose now (a tool's compose-now), waking at each; nil watches none.
|
||||
Asked func(wake func()) error
|
||||
// Hear hears the controller seat's condition events (Consumes) and hands each, by event name, with its body, to
|
||||
// handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass: a machine's
|
||||
// root-free word changed.
|
||||
Hear func(handle func(event string, body []byte)) error
|
||||
|
||||
wake chan struct{}
|
||||
once sync.Once
|
||||
mu sync.Mutex
|
||||
asked bool
|
||||
heard_ bool
|
||||
rootSaid string
|
||||
watched map[string]bool
|
||||
refused map[string]string
|
||||
have map[string]string
|
||||
wake chan struct{}
|
||||
once sync.Once
|
||||
mu sync.Mutex
|
||||
asked bool
|
||||
watched map[string]bool
|
||||
refused map[string]string
|
||||
have map[string]string
|
||||
// held and notAsserted are what the last pass held back and could not create, said again only when they change.
|
||||
held string
|
||||
notAsserted string
|
||||
@@ -122,18 +116,6 @@ func (f *Follower) open() {
|
||||
f.asked = true
|
||||
}
|
||||
}
|
||||
if f.Hear != nil && !f.heard_ {
|
||||
err := f.Hear(func(event string, body []byte) {
|
||||
if IsRootNotFree(body) {
|
||||
f.Wake()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
f.logf("the controller's condition events cannot be heard yet (%v): tried again at the next change", err)
|
||||
} else {
|
||||
f.heard_ = true
|
||||
}
|
||||
}
|
||||
for _, state := range Followed {
|
||||
f.mu.Lock()
|
||||
open := f.watched[state]
|
||||
@@ -193,15 +175,6 @@ func (f *Follower) said(res Result) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if said := strings.Join(res.RootNotFree, "\n"); said != f.rootSaid {
|
||||
f.rootSaid = said
|
||||
if len(res.RootNotFree) == 0 {
|
||||
f.logf("every machine a composition needs a root-free word for is free")
|
||||
}
|
||||
for _, n := range res.RootNotFree {
|
||||
f.logf("root-free: %s (a verified sender is composed as from no root-free machine there)", n)
|
||||
}
|
||||
}
|
||||
for _, a := range res.Asserted {
|
||||
f.logf("asserted %s", a)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
// state it takes effect in two things alone, each by a grant the operator allowed: it publishes memberships (2026-10-25
|
||||
// 22:31), and creates a bucket, log or stream a manifest declares that the bus lacks (2026-10-26 08:54, issue 1790). It
|
||||
// changes no stream the bus holds, writes no grant and no user list the bus node reads, until the operator's take-over,
|
||||
// which the operator asks on their channel and the part runs on their yes, after its refusals.
|
||||
// which this milestone always refuses.
|
||||
package grants
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// The states of other parts the grants part reads, as its manifest's reads name them (`<module>.<state>`).
|
||||
@@ -48,7 +46,7 @@ const (
|
||||
)
|
||||
|
||||
// Invokes is every tool the part calls.
|
||||
var Invokes = []string{CallUsers, CallUserCan, CallStreams, CallRootFree}
|
||||
var Invokes = []string{CallUsers, CallUserCan, CallStreams}
|
||||
|
||||
// Reader reads another part's state, named `<module>.<state>`. A refusal (a read not granted yet, a bucket not there) is
|
||||
// an error the part names as a gap and composes past: it never stops for one.
|
||||
@@ -291,31 +289,6 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
|
||||
in.Manifests[module] = m
|
||||
in.Digests[module] = v.Manifest
|
||||
}
|
||||
// A module the mesh provides itself has no repository the indexer reads: its manifest comes with mesh-sdk, and the
|
||||
// controller's Provide overwrites any row of it at every start, so the provided one always wins here too (issue 1945,
|
||||
// the private network on every machine). An indexer manifest of the same name is said as a gap, never used.
|
||||
if slices.Contains(modules, overlay.Name) {
|
||||
raw, err := json.Marshal(overlay.Manifest())
|
||||
var m catalogue.Manifest
|
||||
if err == nil {
|
||||
m, err = catalogue.ParseManifestForInputs(raw)
|
||||
}
|
||||
in.Gaps = slices.DeleteFunc(in.Gaps, func(g Gap) bool { return g.Input == "manifest of "+overlay.Name })
|
||||
in.Refused = slices.DeleteFunc(in.Refused, func(g Gap) bool { return g.Input == "manifest of "+overlay.Name })
|
||||
if _, held := in.Digests[overlay.Name]; held {
|
||||
in.Gaps = append(in.Gaps, Gap{Input: "manifest of " + overlay.Name, Why: "the indexer also holds one, which is not used: the mesh provides this module's manifest itself, and the controller overwrites any row of it"})
|
||||
}
|
||||
if err != nil {
|
||||
g := Gap{Input: "manifest of " + overlay.Name, Why: "the manifest mesh-sdk provides is refused by its own reader: " + err.Error()}
|
||||
in.Refused = append(in.Refused, g)
|
||||
in.Gaps = append(in.Gaps, g)
|
||||
delete(in.Manifests, overlay.Name)
|
||||
delete(in.Digests, overlay.Name)
|
||||
} else {
|
||||
in.Manifests[overlay.Name] = m
|
||||
in.Digests[overlay.Name] = fmt.Sprintf("provided:sha256:%x", sha256.Sum256(raw))
|
||||
}
|
||||
}
|
||||
for _, module := range modules {
|
||||
if _, ok := in.Manifests[module]; !ok && !gapNamed(in.Gaps, "manifest of "+module) {
|
||||
in.Gaps = append(in.Gaps, Gap{Input: "manifest of " + module,
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// MembershipGaps are what a membership carries that the part cannot compose from the bus.
|
||||
var MembershipGaps = []Gap{
|
||||
{Input: "membership: private network and received contributions", Why: "kept in each node's composition, which only that node reads: carried over from the membership last published, none for a first one"},
|
||||
{Input: "membership: root-free machines", Why: "asked of the controller's root-free verb at each pass and kept for that pass alone; a machine it does not call free (an error included) is composed as not free, and the pass says why"},
|
||||
{Input: "membership: root-free machines", Why: "judged by the controller on the bus at its composition, not kept: a verified sender is composed as from no root-free machine"},
|
||||
}
|
||||
|
||||
// Publisher publishes a membership on the assignments stream (membership.Subject), last per subject, and reads the one
|
||||
|
||||
@@ -6,13 +6,12 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -43,6 +42,9 @@ const (
|
||||
// Emits is every event the part says.
|
||||
var Emits = []string{EventAsserted, EventMembership, EventUserList}
|
||||
|
||||
// Shadow is the switch's one state in this milestone: the bus node follows the controller's list.
|
||||
const Shadow = "shadow"
|
||||
|
||||
// Store is the part's own state, by local name: the only thing the part writes.
|
||||
type Store interface {
|
||||
Get(state, key string) ([]byte, uint64, bool, error)
|
||||
@@ -98,10 +100,7 @@ type Result struct {
|
||||
NotAsserted []string `json:"not_asserted,omitempty"`
|
||||
// Refused is every manifest the indexer holds that this pass could not read, `manifest of <module>: <why>`: its
|
||||
// module counts as placed nowhere (issue 1945).
|
||||
Refused []string `json:"refused,omitempty"`
|
||||
// RootNotFree is each machine the composition needed a root-free word for and did not get one, `<machine>: not
|
||||
// free, <why>`: a verified sender is composed as from no root-free machine there (issue 1945).
|
||||
RootNotFree []string `json:"root_not_free,omitempty"`
|
||||
Refused []string `json:"refused,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
PublishedOrAsserted string `json:"published_or_asserted"`
|
||||
}
|
||||
@@ -119,17 +118,7 @@ type Part struct {
|
||||
Publish Publisher
|
||||
// Create creates a declared stream the bus lacks: the process's connection; nil in the tools, which ask the process.
|
||||
Create Creator
|
||||
// Asker asks the operator (take-over, hand-back): the process's client of the router; nil in the tools, which write
|
||||
// the request and let the process ask it.
|
||||
Asker Asker
|
||||
// Say says a condition to the operator: the process's; nil in the tools.
|
||||
Say func(asks.Condition) error
|
||||
// Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix.
|
||||
Forget func(id string) error
|
||||
Nonce func() string
|
||||
// deciding is set while a take-over is decided, so the composition it makes asks and reconciles nothing.
|
||||
deciding atomic.Bool
|
||||
Now func() time.Time
|
||||
Now func() time.Time
|
||||
|
||||
cache Manifests
|
||||
mu sync.Mutex
|
||||
@@ -183,8 +172,7 @@ func (p *Part) Compose() Result {
|
||||
res.Refused = append(res.Refused, g.Input+": "+g.Why)
|
||||
}
|
||||
recs, left := Records(in)
|
||||
res.RootNotFree = WithRootFree(&recs, p.Call)
|
||||
st := UserListState{V: 1, Mode: p.SwitchState(), LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode,
|
||||
st := UserListState{V: 1, Mode: Shadow, LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode,
|
||||
Gaps: append(append([]Gap(nil), in.Gaps...), MembershipGaps...), At: p.now()}
|
||||
if st.LeftOut == nil {
|
||||
st.LeftOut = []Gap{}
|
||||
@@ -196,7 +184,7 @@ func (p *Part) Compose() Result {
|
||||
users = WithHashes(users, in.Hashes)
|
||||
st.Digest, st.Users = Digest(users), len(users)
|
||||
loaded, err := LoadedUsers(p.Call)
|
||||
st.Compare = Compare(users, loaded, Placed(in)).Excusing(ExcusedBy(MultiHeld(recs)))
|
||||
st.Compare = Compare(users, loaded, Placed(in))
|
||||
if err != nil {
|
||||
st.Compare.Unread = err.Error()
|
||||
}
|
||||
@@ -267,14 +255,6 @@ func (p *Part) Compose() Result {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be written: %v", StateTakeover, err))
|
||||
}
|
||||
}
|
||||
if !p.deciding.Load() {
|
||||
if err := p.RaiseRequested(); err != nil {
|
||||
res.Errors = append(res.Errors, err.Error())
|
||||
}
|
||||
if err := p.Reconcile(); err != nil {
|
||||
res.Errors = append(res.Errors, err.Error())
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -330,3 +310,101 @@ func sortedKeys[V any](m map[string]V) []string {
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Switch is the value of `takeover`: which list the bus node follows, and every act on the switch.
|
||||
type Switch struct {
|
||||
V int `json:"v"`
|
||||
State string `json:"state"`
|
||||
Acts []Act `json:"acts"`
|
||||
}
|
||||
|
||||
// Act is one take-over or hand-back asked, done or refused.
|
||||
type Act struct {
|
||||
Act string `json:"act"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// keptActs is how many acts the switch keeps, newest last.
|
||||
const keptActs = 50
|
||||
|
||||
// Operator says whether a caller is the operator's credential: a person's own bus account (`person.<name>`), which no
|
||||
// session of an agent holds (an agent calls as its machine's tool runner).
|
||||
func Operator(caller string) bool {
|
||||
name, ok := strings.CutPrefix(caller, "person.")
|
||||
return ok && name != ""
|
||||
}
|
||||
|
||||
// NotComposable is why take-over is refused in this milestone, whatever the lists say.
|
||||
const NotComposable = "the enrolment users and the persons cannot be composed: they live only in the controller's store, and take-over waits until they have records on the bus (ADR 0547 clause 9, version 26)"
|
||||
|
||||
// record keeps an act on the switch; a refused write is said in the answer.
|
||||
func (p *Part) record(a Act) string {
|
||||
raw, _, found, err := p.Store.Get(StateTakeover, KeySwitch)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("; the act is not recorded: %v", err)
|
||||
}
|
||||
s := Switch{V: 1, State: Shadow}
|
||||
if found {
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
}
|
||||
s.Acts = append(s.Acts, a)
|
||||
if len(s.Acts) > keptActs {
|
||||
s.Acts = s.Acts[len(s.Acts)-keptActs:]
|
||||
}
|
||||
data, _ := json.Marshal(s)
|
||||
if _, err := p.Store.Put(StateTakeover, KeySwitch, data); err != nil {
|
||||
return fmt.Sprintf("; the act is not recorded: %v", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TakeOver is the operator's switch to the grants part's list. It is refused without the operator's credential, while
|
||||
// any user of the shadow list differs from the loaded list (but those both agree are gone), and in this milestone
|
||||
// always, since the enrolment users and the persons cannot be composed. Every ask is recorded.
|
||||
func (p *Part) TakeOver(caller, why string) error {
|
||||
a := Act{Act: "take-over", By: caller, Why: why, At: p.now()}
|
||||
if !Operator(caller) {
|
||||
a.Refused = fmt.Sprintf("take-over is the operator's alone: %s is not a person's account", caller)
|
||||
return fmt.Errorf("%s%s", a.Refused, p.record(a))
|
||||
}
|
||||
res := p.Compose()
|
||||
var reasons []string
|
||||
if c := res.UserList.Compare; !c.Equal() {
|
||||
if c.Unread != "" {
|
||||
reasons = append(reasons, "the loaded list could not be read: "+c.Unread)
|
||||
}
|
||||
if d := c.Differing(); len(d) > 0 {
|
||||
reasons = append(reasons, fmt.Sprintf("%d users differ between the shadow list and the loaded list: %s", len(d), strings.Join(d, ", ")))
|
||||
}
|
||||
}
|
||||
cr, gone := res.UserList.Credentials, res.UserList.Compare.Gone
|
||||
var unhashed []string
|
||||
for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) {
|
||||
if !slices.Contains(gone, u) {
|
||||
unhashed = append(unhashed, u)
|
||||
}
|
||||
}
|
||||
if len(unhashed) > 0 {
|
||||
sort.Strings(unhashed)
|
||||
reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s",
|
||||
len(unhashed), strings.Join(unhashed, ", ")))
|
||||
}
|
||||
reasons = append(reasons, NotComposable)
|
||||
a.Refused = "take-over refused: " + strings.Join(reasons, "; ")
|
||||
return fmt.Errorf("%s%s", a.Refused, p.record(a))
|
||||
}
|
||||
|
||||
// HandBack is the operator's switch back to the controller's list. Refused without the operator's credential, and
|
||||
// while the part is in shadow, since the bus node follows the controller's list already. Every ask is recorded.
|
||||
func (p *Part) HandBack(caller, why string) error {
|
||||
a := Act{Act: "hand-back", By: caller, Why: why, At: p.now()}
|
||||
if !Operator(caller) {
|
||||
a.Refused = fmt.Sprintf("hand-back is the operator's alone: %s is not a person's account", caller)
|
||||
return fmt.Errorf("%s%s", a.Refused, p.record(a))
|
||||
}
|
||||
a.Refused = "nothing to hand back: the grants part is in shadow and never took over, so the bus node follows the controller's list already"
|
||||
return fmt.Errorf("%s%s", a.Refused, p.record(a))
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
package grants
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
)
|
||||
|
||||
// CallRootFree is the controller seat's verb that judges machines root-free (novox/hq ADR 0259 §8): the one judgement
|
||||
// the composer needs and the part cannot make from the bus (issue 1945). Only the controller answers it, on purpose.
|
||||
const CallRootFree = "seat:mesh-controller.root-free"
|
||||
|
||||
// The controller seat's condition events the part hears (never a timer): a machine's root-free word changes with the
|
||||
// raising and the clearing of its `root-not-free` condition.
|
||||
const (
|
||||
EventConditionRaised = "mesh-controller.condition-raised"
|
||||
EventConditionCleared = "mesh-controller.condition-cleared"
|
||||
// KindRootNotFree is the condition kind whose events wake a pass.
|
||||
KindRootNotFree = "root-not-free"
|
||||
)
|
||||
|
||||
// Consumes is every event the part hears, as its manifest's consumes name them.
|
||||
var Consumes = []string{EventConditionRaised, EventConditionCleared}
|
||||
|
||||
// EventSubject is the bus subject of a controller seat event named `mesh-controller.<event>`.
|
||||
func EventSubject(event string) string {
|
||||
return "mesh.seat.mesh-controller.event." + strings.TrimPrefix(event, "mesh-controller.")
|
||||
}
|
||||
|
||||
// IsRootNotFree says a condition event's body names the `root-not-free` kind (its `kind`, or the last part of its
|
||||
// `key`, `<scope>.<id>.<kind>`). An unreadable body wakes a pass too: a pass is cheap, a missed change is not.
|
||||
func IsRootNotFree(body []byte) bool {
|
||||
var c struct {
|
||||
Kind string `json:"kind"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if json.Unmarshal(body, &c) != nil {
|
||||
return true
|
||||
}
|
||||
return c.Kind == KindRootNotFree || (c.Kind == "" && strings.HasSuffix(c.Key, "."+KindRootNotFree)) || (c.Kind == "" && c.Key == "")
|
||||
}
|
||||
|
||||
// RootFreeNodes are the machines a composition needs a root-free word for: where the router's seat is held and where a
|
||||
// kinded seat's holder declares `verified-sender`. None when no holder declares it. Sorted. Pure.
|
||||
func RootFreeNodes(r buscompose.Records) []string {
|
||||
var nodes []string
|
||||
need := false
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == "operator-channel" && !slices.Contains(nodes, node) {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
if s.Kinded && s.Kind != "" && slices.Contains(s.Capabilities, "verified-sender") {
|
||||
need = true
|
||||
if !slices.Contains(nodes, node) {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !need {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
return nodes
|
||||
}
|
||||
|
||||
// verdict is one machine's answer of the controller's root-free verb.
|
||||
type verdict struct {
|
||||
Machine string `json:"machine"`
|
||||
Free bool `json:"free"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// WithRootFree fills r.RootFree for the machines the composition needs by asking the controller once, and answers a
|
||||
// line for each machine that is not free, `<machine>: <why>`. It fails closed: an error, no answer or a machine
|
||||
// left out is not free, and says so. Pure but for the one call; the answer is kept for this pass alone.
|
||||
func WithRootFree(r *buscompose.Records, call Caller) []string {
|
||||
nodes := RootFreeNodes(*r)
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
free := map[string]verdict{}
|
||||
var failure string
|
||||
if call == nil {
|
||||
failure = "no way to ask the controller here"
|
||||
} else if raw, err := call.Call(CallRootFree, map[string]any{"machines": nodes}); err != nil {
|
||||
failure = "the controller's root-free word could not be asked: " + err.Error()
|
||||
} else {
|
||||
var body struct {
|
||||
Machines []verdict `json:"machines"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
failure = "the controller's root-free answer could not be read: " + err.Error()
|
||||
}
|
||||
for _, v := range body.Machines {
|
||||
free[v.Machine] = v
|
||||
}
|
||||
}
|
||||
r.RootFree = map[string]bool{}
|
||||
var notes []string
|
||||
for _, n := range nodes {
|
||||
v, answered := free[n]
|
||||
switch {
|
||||
case answered && v.Free:
|
||||
r.RootFree[n] = true
|
||||
case failure != "":
|
||||
notes = append(notes, fmt.Sprintf("%s: not free, %s", n, failure))
|
||||
case !answered:
|
||||
notes = append(notes, fmt.Sprintf("%s: not free, the controller named no verdict for it", n))
|
||||
default:
|
||||
notes = append(notes, fmt.Sprintf("%s: not free, %s", n, v.Why))
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
package grants
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The switch between the controller's user list and the grants part's (ADR 0547 clause 8). The operator's word is a
|
||||
// warrant: the operator's answer to an ask on a channel that proves who answered, never a caller's name, since an
|
||||
// operator's session and every agent's call as the same machine account (the operator, 2026-10-26).
|
||||
|
||||
// Shadow is the switch's state until take-over: the bus node follows the controller's list. TookOver is its state after.
|
||||
const (
|
||||
Shadow = "shadow"
|
||||
TookOver = "took-over"
|
||||
)
|
||||
|
||||
// KeyRequest is the key in StateTakeover where a tool leaves its request for the process to ask.
|
||||
const KeyRequest = "request"
|
||||
|
||||
// The two acts the operator may warrant.
|
||||
const (
|
||||
ActTakeOver = "take-over"
|
||||
ActHandBack = "hand-back"
|
||||
)
|
||||
|
||||
// Switch is the value of `takeover`: which list the bus node follows, every act on the switch and the ask open now.
|
||||
type Switch struct {
|
||||
V int `json:"v"`
|
||||
State string `json:"state"`
|
||||
// Open is the id of the ask the operator has not answered yet, empty when none.
|
||||
Open string `json:"open,omitempty"`
|
||||
// Refusing are the acts whose refusal condition was said and not cleared yet.
|
||||
Refusing []string `json:"refusing,omitempty"`
|
||||
Acts []Act `json:"acts"`
|
||||
}
|
||||
|
||||
// Act is one take-over or hand-back asked, answered, done or refused.
|
||||
type Act struct {
|
||||
Act string `json:"act"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
// Ask is the router's ask of the act; Said what became of it: asked, switched, declined, expired, refused.
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Said string `json:"said,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// Request is what a tool leaves for the process to ask: written once per act, marked raised when asked.
|
||||
type Request struct {
|
||||
ID string `json:"id"`
|
||||
Act string `json:"act"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Raised bool `json:"raised,omitempty"`
|
||||
// RaisedAt is when it was asked, and Failed why asking it failed (the ask is then closed as refused).
|
||||
RaisedAt time.Time `json:"raised_at,omitempty"`
|
||||
Failed string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// Asker is the client of the router the part asks through (mesh-sdk asker.Client).
|
||||
type Asker interface {
|
||||
Ask(a asks.Ask) error
|
||||
// Where is the router's record of an ask: its state and its warrant, if any.
|
||||
Where(id string) (string, *asks.Warrant, error)
|
||||
// Take hands a warrant to act once (a warrant the process missed is caught up through it).
|
||||
Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error)
|
||||
}
|
||||
|
||||
// askMargin is how long past an ask's expiry the router is given to say it ended, before the part closes it itself;
|
||||
// recordGrace how long a just-asked ask may be missing from the router's record.
|
||||
const (
|
||||
askMargin = 5 * time.Minute
|
||||
recordGrace = time.Minute
|
||||
)
|
||||
|
||||
// keptActs is how many acts the switch keeps, newest last.
|
||||
const keptActs = 50
|
||||
|
||||
func (p *Part) readSwitch() (Switch, error) {
|
||||
s := Switch{V: 1, State: Shadow}
|
||||
raw, _, found, err := p.Store.Get(StateTakeover, KeySwitch)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if found {
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (p *Part) writeSwitch(s Switch) error {
|
||||
if len(s.Acts) > keptActs {
|
||||
s.Acts = s.Acts[len(s.Acts)-keptActs:]
|
||||
}
|
||||
if s.Acts == nil {
|
||||
s.Acts = []Act{}
|
||||
}
|
||||
data, _ := json.Marshal(s)
|
||||
_, err := p.Store.Put(StateTakeover, KeySwitch, data)
|
||||
return err
|
||||
}
|
||||
|
||||
// SwitchState is which list the bus node follows now.
|
||||
func (p *Part) SwitchState() string {
|
||||
if s, err := p.readSwitch(); err == nil && s.State != "" {
|
||||
return s.State
|
||||
}
|
||||
return Shadow
|
||||
}
|
||||
|
||||
// TakeOver is the tool: it asks the operator to switch the bus node to the grants part's list. It writes the request,
|
||||
// and the part's process asks it on the operator's channel; only the operator's yes (a warrant) runs the switch, after
|
||||
// the refusals of Decide. Asking again while an ask is open is refused.
|
||||
func (p *Part) TakeOver(caller, why string) (any, error) { return p.request(ActTakeOver, caller, why) }
|
||||
|
||||
// HandBack is the tool: it asks the operator to switch the bus node back to the controller's list.
|
||||
func (p *Part) HandBack(caller, why string) (any, error) { return p.request(ActHandBack, caller, why) }
|
||||
|
||||
// nonce is four hex digits that keep two asks of one second apart.
|
||||
func (p *Part) nonce() string {
|
||||
if p.Nonce != nil {
|
||||
return p.Nonce()
|
||||
}
|
||||
var b [2]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func (p *Part) request(act, caller, why string) (any, error) {
|
||||
s, err := p.readSwitch()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the switch cannot be read: %w", err)
|
||||
}
|
||||
a := Act{Act: act, By: caller, Why: why, At: p.now()}
|
||||
if s.Open != "" {
|
||||
return nil, fmt.Errorf("%s is already asked: the ask %s waits for the operator's answer", act, s.Open)
|
||||
}
|
||||
if act == ActHandBack && s.State == Shadow {
|
||||
a.Refused = "nothing to hand back: the grants part is in shadow and never took over, so the bus node follows the controller's list already"
|
||||
s.Acts = append(s.Acts, a)
|
||||
_ = p.writeSwitch(s)
|
||||
return nil, fmt.Errorf("%s", a.Refused)
|
||||
}
|
||||
if act == ActTakeOver && s.State == TookOver {
|
||||
return nil, fmt.Errorf("the bus node follows the grants part's list already")
|
||||
}
|
||||
a.Ask = act + "-" + p.now().Format("20060102t150405") + "-" + p.nonce()
|
||||
a.Said = "asked"
|
||||
req, _ := json.Marshal(Request{ID: a.Ask, Act: act, By: caller, Why: why, At: a.At})
|
||||
if _, err := p.Store.Put(StateTakeover, KeyRequest, req); err != nil {
|
||||
return nil, fmt.Errorf("the request could not be written: %w", err)
|
||||
}
|
||||
s.Open = a.Ask
|
||||
s.Acts = append(s.Acts, a)
|
||||
if err := p.writeSwitch(s); err != nil {
|
||||
return nil, fmt.Errorf("the act could not be recorded: %w", err)
|
||||
}
|
||||
// The process asks at its next pass: wake it.
|
||||
_, _ = p.AskCompose(caller)
|
||||
return map[string]any{"asked": a.Ask, "says": "the part's process asks the operator on their channel; only the operator's yes records the switch, and it takes effect when the bus node loads the grants part's list. grants_take_over_state shows where it stands"}, nil
|
||||
}
|
||||
|
||||
// RaiseRequested is the process's: it asks the operator the request a tool left, once. Where the part has no asker (the
|
||||
// tools) it does nothing.
|
||||
func (p *Part) RaiseRequested() error {
|
||||
if p.Asker == nil {
|
||||
return nil
|
||||
}
|
||||
raw, rev, found, err := p.Store.Get(StateTakeover, KeyRequest)
|
||||
if err != nil || !found {
|
||||
if err != nil {
|
||||
return fmt.Errorf("its own %s %s cannot be read: %v", StateTakeover, KeyRequest, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var r Request
|
||||
if json.Unmarshal(raw, &r) != nil || r.Raised || r.Failed != "" || r.ID == "" {
|
||||
return nil
|
||||
}
|
||||
if s, err := p.readSwitch(); err != nil || s.Open != r.ID {
|
||||
return nil // an ask no longer open is not asked
|
||||
}
|
||||
ask, err := p.AskOf(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.Asker.Ask(ask); err != nil {
|
||||
// Closed as refused, and the half-made record the asker kept before publishing is taken away, so nothing stays open.
|
||||
r.Failed = err.Error()
|
||||
data, _ := json.Marshal(r)
|
||||
_, _ = p.Store.Put(StateTakeover, KeyRequest, data)
|
||||
if p.Forget != nil {
|
||||
_ = p.Forget(r.ID)
|
||||
}
|
||||
if s, rerr := p.readSwitch(); rerr == nil {
|
||||
_ = p.finish(s, Act{Act: r.Act, By: "operator", At: p.now(), Ask: r.ID, Said: "refused", Refused: "the operator could not be asked: " + err.Error()})
|
||||
}
|
||||
return fmt.Errorf("the operator could not be asked the %s: %v", r.Act, err)
|
||||
}
|
||||
r.Raised, r.RaisedAt = true, p.now()
|
||||
data, _ := json.Marshal(r)
|
||||
if _, err := p.Store.Put(StateTakeover, KeyRequest, data); err != nil {
|
||||
return fmt.Errorf("the ask %s was asked and its request could not be marked: %v", r.ID, err)
|
||||
}
|
||||
_ = rev
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconcile closes an ask that can stay open for ever, once per pass while one is open: a warrant the process missed is
|
||||
// caught up from the router's record, one that ended with none is closed, and one past its expiry and a margin is closed
|
||||
// as expired by the part itself.
|
||||
func (p *Part) Reconcile() error {
|
||||
if p.Asker == nil {
|
||||
return nil
|
||||
}
|
||||
s, err := p.readSwitch()
|
||||
if err != nil || s.Open == "" {
|
||||
return err
|
||||
}
|
||||
act := actOf(s.Open)
|
||||
at := p.now()
|
||||
for _, a := range s.Acts {
|
||||
if a.Ask == s.Open {
|
||||
at = a.At
|
||||
break
|
||||
}
|
||||
}
|
||||
state, w, err := p.Asker.Where(s.Open)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the router's record of the ask %s cannot be read: %v", s.Open, err)
|
||||
}
|
||||
if w != nil {
|
||||
h, herr := p.Asker.Take(*w, p.Warranted)
|
||||
p.Said(h, *w, herr)
|
||||
if cur, rerr := p.readSwitch(); rerr == nil && cur.Open == s.Open {
|
||||
_ = p.finish(cur, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: string(w.Outcome)})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
raw, _, found, _ := p.Store.Get(StateTakeover, KeyRequest)
|
||||
var r Request
|
||||
if found {
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
}
|
||||
switch {
|
||||
case p.now().After(at.Add(asks.ApproveLasts + askMargin)):
|
||||
return p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: string(asks.OutcomeExpired)})
|
||||
case state == "" && r.ID == s.Open && r.Raised && p.now().After(r.RaisedAt.Add(recordGrace)):
|
||||
return p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: "ended", Refused: "the router holds no record of the ask"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func actOf(id string) string {
|
||||
if strings.HasPrefix(id, ActHandBack+"-") {
|
||||
return ActHandBack
|
||||
}
|
||||
return ActTakeOver
|
||||
}
|
||||
|
||||
// Following says whether the bus node has loaded the grants part's list: "yes" when its loaded digest equals the part's,
|
||||
// "not yet" when it loaded another, "unknown" when its have says none.
|
||||
func (p *Part) Following() string {
|
||||
st := p.Last().UserList
|
||||
if st.Loaded == nil || st.Digest == "" {
|
||||
return "unknown: the bus module's have names no loaded list"
|
||||
}
|
||||
if st.Loaded.Wanted == st.Digest || st.Loaded.Digest == st.Digest {
|
||||
return "yes"
|
||||
}
|
||||
return "not yet: the bus node loaded another list"
|
||||
}
|
||||
|
||||
// AskOf is the ask of a request, in the words the operator reads: what changes, why, what it risks. Its yes binds the act.
|
||||
func (p *Part) AskOf(r Request) (asks.Ask, error) {
|
||||
verb, headline, does, stays, expl := "grants_take_over", "Switch the bus's user list to the grants part?", "The switch is recorded; it takes effect when the bus node loads the grants part's list.",
|
||||
"The controller's list stays in force.", "The grants part has composed the user list the bus holds today. Your yes records the switch; it takes effect when the bus node loads the part's list.\n"+
|
||||
"If the lists differ in any user, or a user has no hash in the vault, the switch is refused and you are told which."
|
||||
if r.Act == ActHandBack {
|
||||
verb, headline, does, stays, expl = "grants_hand_back", "Give the bus's user list back to the controller?", "The switch back is recorded; it takes effect when the bus node loads the controller's list.",
|
||||
"The grants part's list stays in force.", "The bus follows the grants part's list now.\nThe controller still writes its list until it loses its lease, so the bus can follow it again."
|
||||
}
|
||||
bind, err := asks.ActDigest(asks.Act{"verb": verb, "module": "mesh-grants", "ask": r.ID})
|
||||
if err != nil {
|
||||
return asks.Ask{}, err
|
||||
}
|
||||
return asks.Ask{ID: r.ID, Headline: headline, Explanation: expl, Who: asks.Operator,
|
||||
Options: []asks.Option{{ID: "yes", Label: "Yes, switch", Does: does, Level: asks.Approve, Binds: bind},
|
||||
{ID: "no", Label: "No, leave it", Does: stays, Level: asks.Acknowledge}},
|
||||
Expires: p.now().Add(asks.ApproveLasts - time.Minute), OnExpiry: stays + " Nothing changes.", About: "mesh-grants-" + r.Act,
|
||||
Details: "Asked by " + r.By + ": " + r.Why}, nil
|
||||
}
|
||||
|
||||
// Warranted is the process's act on the operator's answer (asker.Client.Listen hands it once, after checking the ask, the
|
||||
// option and its level). A no is recorded; a yes runs the switch after the refusals, and a refusal is said to the operator.
|
||||
func (p *Part) Warranted(o asks.Option, w asks.Warrant) error {
|
||||
s, err := p.readSwitch()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
act := ActTakeOver
|
||||
if strings.HasPrefix(w.Ask, ActHandBack+"-") {
|
||||
act = ActHandBack
|
||||
}
|
||||
if s.Open != w.Ask {
|
||||
return fmt.Errorf("the warrant answers the ask %s, and the ask open is %q: nothing is switched", w.Ask, s.Open)
|
||||
}
|
||||
a := Act{Act: act, By: "operator", At: p.now(), Ask: w.Ask}
|
||||
if o.ID != "yes" {
|
||||
a.Said = "declined"
|
||||
return p.finish(s, a)
|
||||
}
|
||||
var reasons []string
|
||||
if act == ActTakeOver {
|
||||
reasons = p.Decide()
|
||||
if s, err = p.readSwitch(); err != nil { // Decide clears a refusal and writes the switch: read it again
|
||||
return err
|
||||
}
|
||||
} else if s.State == Shadow {
|
||||
reasons = []string{"nothing to hand back: the bus node follows the controller's list already"}
|
||||
}
|
||||
if len(reasons) > 0 {
|
||||
a.Said, a.Refused = "refused", strings.Join(reasons, "; ")
|
||||
p.sayRefused(&s, act, a.Refused)
|
||||
return p.finish(s, a)
|
||||
}
|
||||
p.clearRefused(&s)
|
||||
a.Said = "recorded"
|
||||
if act == ActTakeOver {
|
||||
s.State = TookOver
|
||||
} else {
|
||||
s.State = Shadow
|
||||
}
|
||||
if p.Emit != nil {
|
||||
_ = p.Emit.Emit(EventUserList, map[string]any{"switch": s.State})
|
||||
}
|
||||
return p.finish(s, a)
|
||||
}
|
||||
|
||||
// Said is the process's note of an ask that ended with no yes or no (expired, or refused by the router): the switch
|
||||
// stays, and the ask is closed so the operator can be asked again.
|
||||
func (p *Part) Said(h asker.Handled, w asks.Warrant, _ error) {
|
||||
s, err := p.readSwitch()
|
||||
if err != nil || s.Open != w.Ask {
|
||||
return
|
||||
}
|
||||
_ = p.finish(s, Act{Act: actOf(w.Ask), By: "operator", At: p.now(), Ask: w.Ask, Said: string(w.Outcome) + " (" + string(h) + ")"})
|
||||
}
|
||||
|
||||
func (p *Part) finish(s Switch, a Act) error {
|
||||
if s.Open == a.Ask {
|
||||
s.Open = ""
|
||||
}
|
||||
s.Acts = append(s.Acts, a)
|
||||
return p.writeSwitch(s)
|
||||
}
|
||||
|
||||
// Decide is what stands between a take-over and the grants part's list, read from a composition made now: the loaded list
|
||||
// not read, any user the two lists keep apart (one the loaded list has that the shadow cannot compose among them, such as
|
||||
// an enrolment user or a person whose records are in the controller's store alone), and any user with no hash in the vault.
|
||||
// Nothing here but what is true now: a take-over is refused for these and for no other reason. Empty means none.
|
||||
func (p *Part) Decide() []string {
|
||||
p.deciding.Store(true)
|
||||
res := p.Compose()
|
||||
p.deciding.Store(false)
|
||||
var reasons []string
|
||||
if c := res.UserList.Compare; !c.Equal() {
|
||||
if c.Unread != "" {
|
||||
reasons = append(reasons, "the loaded list could not be read: "+c.Unread)
|
||||
}
|
||||
if d := c.Differing(); len(d) > 0 {
|
||||
reasons = append(reasons, fmt.Sprintf("%d users differ between the shadow list and the loaded list: %s", len(d), strings.Join(d, ", ")))
|
||||
}
|
||||
}
|
||||
cr, gone := res.UserList.Credentials, res.UserList.Compare.Gone
|
||||
var unhashed []string
|
||||
for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) {
|
||||
if !contains(gone, u) {
|
||||
unhashed = append(unhashed, u)
|
||||
}
|
||||
}
|
||||
if len(unhashed) > 0 {
|
||||
sort.Strings(unhashed)
|
||||
reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s",
|
||||
len(unhashed), strings.Join(unhashed, ", ")))
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
if s, err := p.readSwitch(); err == nil && len(s.Refusing) > 0 {
|
||||
p.clearRefused(&s)
|
||||
_ = p.writeSwitch(s)
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sayRefused tells the operator a yes was given and the switch refused, with the reasons (the users that differ named). The
|
||||
// condition stays raised until a switch is recorded or a later Decide finds no reason (clearRefused).
|
||||
func (p *Part) sayRefused(s *Switch, act, why string) {
|
||||
if p.Say == nil {
|
||||
return
|
||||
}
|
||||
c := asks.Condition{Key: "mesh-grants." + act + "-refused", Severity: asks.SeverityWarning, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"},
|
||||
State: asks.StateRaised, Summary: act + " refused: " + why, Headline: "The user list switch was refused", Needs: "a look at why",
|
||||
Explanation: asks.NeedsYou + "a look at why. You said yes to the switch and it was refused: " + why, Resolved: "the switch is asked again and goes through"}
|
||||
if p.Say(c) == nil && !contains(s.Refusing, act) {
|
||||
s.Refusing = append(s.Refusing, act)
|
||||
}
|
||||
}
|
||||
|
||||
// clearRefused clears every refusal condition said and not cleared: on a recorded switch, a hand-back, or a Decide with no reason.
|
||||
func (p *Part) clearRefused(s *Switch) {
|
||||
if p.Say == nil {
|
||||
return
|
||||
}
|
||||
var left []string
|
||||
for _, act := range s.Refusing {
|
||||
c := asks.Condition{Key: "mesh-grants." + act + "-refused", Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}, State: asks.StateCleared}
|
||||
if p.Say(c) != nil {
|
||||
left = append(left, act)
|
||||
}
|
||||
}
|
||||
s.Refusing = left
|
||||
}
|
||||
|
||||
// TakeOverState is the tool take-over-state: the switch, the request, and what became of each ask.
|
||||
func (p *Part) TakeOverState() (any, error) {
|
||||
s, err := p.readSwitch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"state": s.State, "open": s.Open, "acts": s.Acts, "bus_node_loaded_grants_list": p.Following(),
|
||||
"says": "a recorded switch takes effect when the bus node loads the grants part's list; bus_node_loaded_grants_list says whether it has (after a hand-back the controller's list is meant, and it then reads not yet)"}
|
||||
if raw, _, found, err := p.Store.Get(StateTakeover, KeyRequest); err == nil && found {
|
||||
out["request"] = json.RawMessage(raw)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -13,18 +13,17 @@ import (
|
||||
// The part's tools, as its manifest lists them. Module tools in this milestone: a `mesh-*` seat must be in mesh-sdk's own
|
||||
// seat list, which the running controller's composer does not know, so the part claims no seat until take-over.
|
||||
const (
|
||||
ToolGrants = "grants_grants"
|
||||
ToolMembership = "grants_membership"
|
||||
ToolUserList = "grants_user_list"
|
||||
ToolComposeNow = "grants_compose_now"
|
||||
ToolAssert = "grants_assert"
|
||||
ToolTakeOver = "grants_take_over"
|
||||
ToolTakeOverState = "grants_take_over_state"
|
||||
ToolHandBack = "grants_hand_back"
|
||||
ToolGrants = "grants_grants"
|
||||
ToolMembership = "grants_membership"
|
||||
ToolUserList = "grants_user_list"
|
||||
ToolComposeNow = "grants_compose_now"
|
||||
ToolAssert = "grants_assert"
|
||||
ToolTakeOver = "grants_take_over"
|
||||
ToolHandBack = "grants_hand_back"
|
||||
)
|
||||
|
||||
// Tools is every tool, in the manifest's order.
|
||||
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState}
|
||||
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack}
|
||||
|
||||
// WritesNoGrant is what grants says of the part's own grants in this milestone.
|
||||
const WritesNoGrant = "none: the grants part writes no grants.<module>.<fp> before ADR 0547's third milestone (clause 5); the controller's are beside"
|
||||
@@ -71,9 +70,8 @@ func (p *Part) MembershipOf(node, module string) (any, error) {
|
||||
}
|
||||
in := ReadInputs(p.Read, &p.cache)
|
||||
recs, left := Records(in)
|
||||
notFree := WithRootFree(&recs, p.Call)
|
||||
m, ok := Memberships(recs)[node+"."+module]
|
||||
out := map[string]any{"node": node, "module": module, "gaps": MembershipGaps, "root_free": recs.RootFree, "root_not_free": notFree}
|
||||
out := map[string]any{"node": node, "module": module, "gaps": MembershipGaps}
|
||||
if ok {
|
||||
out["composed"] = m
|
||||
} else {
|
||||
|
||||
@@ -5,13 +5,11 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// User is one bus user and its grants, as the bus module's nats_users answers them: never a password hash.
|
||||
@@ -236,9 +234,7 @@ type Comparison struct {
|
||||
Dropped []string `json:"dropped"`
|
||||
Gone []string `json:"gone,omitempty"`
|
||||
Differ []UserDiff `json:"differ"`
|
||||
// Excused are the subjects the comparison does not count, each with why (Comparison.Excusing).
|
||||
Excused []Excuse `json:"excused,omitempty"`
|
||||
Same int `json:"same"`
|
||||
Same int `json:"same"`
|
||||
// Unread says the loaded list could not be read, and why: nothing is compared then.
|
||||
Unread string `json:"unread,omitempty"`
|
||||
}
|
||||
@@ -329,96 +325,3 @@ func Placed(in Inputs) func(user string) bool {
|
||||
return slices.Contains(in.Nodes, parts[0]) && slices.Contains(in.Assigned[parts[0]], parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Excuse is one subject the comparison does not count as a difference between the lists, and why.
|
||||
type Excuse struct {
|
||||
User string `json:"user"`
|
||||
Subject string `json:"subject"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// Excusing removes from the differences the subjects f excuses (added: the shadow grants it and the loaded list does not),
|
||||
// naming each in Excused; a user left with no difference counts as the same. Pure.
|
||||
func (c Comparison) Excusing(f func(user, subject string, added, subscribe bool) string) Comparison {
|
||||
var differ []UserDiff
|
||||
for _, d := range c.Differ {
|
||||
keep := func(list []string, added, subscribe bool) []string {
|
||||
var out []string
|
||||
for _, s := range list {
|
||||
if why := f(d.User, s, added, subscribe); why != "" {
|
||||
c.Excused = append(c.Excused, Excuse{User: d.User, Subject: s, Why: why})
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
d.PublishAdded, d.PublishDropped = keep(d.PublishAdded, true, false), keep(d.PublishDropped, false, false)
|
||||
d.SubscribeAdded, d.SubscribeDropped = keep(d.SubscribeAdded, true, true), keep(d.SubscribeDropped, false, true)
|
||||
if len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped) == 0 && d.Responses == "" {
|
||||
c.Same++
|
||||
continue
|
||||
}
|
||||
differ = append(differ, d)
|
||||
}
|
||||
if differ == nil {
|
||||
differ = []UserDiff{}
|
||||
}
|
||||
c.Differ = differ
|
||||
return c
|
||||
}
|
||||
|
||||
var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.\*)?$`)
|
||||
var seatService = regexp.MustCompile(`^\$SRV\.(?:INFO|PING|STATS)\.([^.]+)(?:\.>)?$`)
|
||||
|
||||
// ExcusedBy is the excuse of a comparison until placement lives in the catalogue (the operator, 2026-10-26): a verb the
|
||||
// seat catalogue retired on purpose, which the shadow no longer grants, is no difference; and what the shadow grants more
|
||||
// for a mesh seat that is claimed on several machines (the standing gap seat holdings: it composes the seat as held on each)
|
||||
// is named and not counted, for a user on a machine that claims the seat and only as a subscription (what it serves), since
|
||||
// a subscription to a seat's verbs on a machine whose holder does not answer them harms nothing and is gone when holdings
|
||||
// follow the catalogue. Nothing else is excused: an added publish, a grant of a user on another machine, or the loss of any
|
||||
// other subject, keeps the lists apart.
|
||||
func ExcusedBy(multiHeld map[string][]string) func(user, subject string, added, subscribe bool) string {
|
||||
// holds says the user is on a machine where the seat is claimed: the machine's tool runner, or a module of it.
|
||||
holds := func(user, seat string) bool {
|
||||
node, _, _ := strings.Cut(user, ".")
|
||||
return slices.Contains(multiHeld[seat], node)
|
||||
}
|
||||
return func(user, subject string, added, subscribe bool) string {
|
||||
if m := seatSubject.FindStringSubmatch(subject); m != nil {
|
||||
if !added && subscribe && retiredVerb(m[1], m[2]) {
|
||||
return "a verb the seat catalogue retired"
|
||||
}
|
||||
if added && subscribe && holds(user, m[1]) {
|
||||
return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue"
|
||||
}
|
||||
}
|
||||
if m := seatService.FindStringSubmatch(subject); m != nil && added && subscribe && holds(user, m[1]) {
|
||||
return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// MultiHeld are the mesh seats (held once for the mesh) that more than one machine's module claims, each with the
|
||||
// machines that claim it.
|
||||
func MultiHeld(r buscompose.Records) map[string][]string {
|
||||
nodes := map[string][]string{}
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, s := range d.Holds {
|
||||
if (s.Scope == "" || s.Scope == catalogue.ScopeMesh) && !slices.Contains(nodes[s.Name], node) {
|
||||
nodes[s.Name] = append(nodes[s.Name], node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for seat, ns := range nodes {
|
||||
if len(ns) < 2 {
|
||||
delete(nodes, seat)
|
||||
continue
|
||||
}
|
||||
sort.Strings(ns)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
@@ -13,18 +13,10 @@
|
||||
"node-engine.have",
|
||||
"mesh-vault.bus-accounts"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-controller.condition-raised",
|
||||
"mesh-controller.condition-cleared"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"invokes": [
|
||||
"nats.nats_streams",
|
||||
"nats.nats_user_can",
|
||||
"nats.nats_users",
|
||||
"seat:mesh-controller.root-free"
|
||||
"nats.nats_users"
|
||||
],
|
||||
"emits": [
|
||||
"asserted",
|
||||
@@ -38,7 +30,6 @@
|
||||
"grants_compose_now",
|
||||
"grants_assert",
|
||||
"grants_take_over",
|
||||
"grants_take_over_state",
|
||||
"grants_hand_back"
|
||||
],
|
||||
"state": [
|
||||
|
||||
Reference in New Issue
Block a user