Merge pull request 'Design 47's last milestones as one release: the contracts for health, control records, provisioning and history leaving the controller (issues 774, 1126, 1952, 1953, 1954, 1955, 1289)' (#133) from feat/774-l3-reland into main
This commit was merged in pull request #133.
This commit is contained in:
+11
-3
@@ -228,8 +228,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
_ = json.Unmarshal(r, &t)
|
||||
return t.Passed, nil
|
||||
}
|
||||
// Every test step of the level runs, also after one that did not pass, so one check names every failing test of the
|
||||
// level and not only the first (issue 1921); the level as a whole passes only when each of its steps did.
|
||||
// The packages (or Node directories) whose tests the change touches run first, and a failure among them ends the level
|
||||
// there, so a change's own failing test is answered without the rest of the suite (issue 1289; ADR 0444). Past them every
|
||||
// test step of the level runs, also after one that did not pass, so one check names every failing test of the rest of
|
||||
// the level and not only the first (issue 1921); the level passes only when each of its steps did.
|
||||
all := true
|
||||
if s.Digest == nil {
|
||||
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
|
||||
@@ -244,6 +246,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
}
|
||||
all = all && passed
|
||||
}
|
||||
if !all {
|
||||
return false, nil // the change's own tests failed: the others are not run (issue 1289)
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
@@ -279,7 +284,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("the %s level: %w", level, err)
|
||||
}
|
||||
all = all && passed // the packages left still run (issue 1921)
|
||||
all = all && passed // past the change's own packages the others still run (issue 1921)
|
||||
if priority && !all {
|
||||
return false, nil // the change's own tests failed: the others are not run (issue 1289)
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
+52
-30
@@ -113,7 +113,7 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
|
||||
// verdicts, tests the packages whose tests changed first, then the rest also after a failure (issue 1921), and runs no
|
||||
// verdicts, tests the packages whose tests changed first and, when one of them fails, no other (issue 1289), and runs no
|
||||
// level after one that did not pass (ADR 0484 clause 13).
|
||||
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
r := &recorder{
|
||||
@@ -129,8 +129,7 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
got := strings.Join(r.calls, "\n")
|
||||
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
|
||||
digest {"level":"unit","workspace":"/w"}
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
|
||||
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
|
||||
if got != want {
|
||||
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
@@ -139,7 +138,7 @@ test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
|
||||
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
|
||||
}
|
||||
u := a.Levels[0]
|
||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
|
||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil {
|
||||
t.Fatalf("the unit level %+v", u)
|
||||
}
|
||||
|
||||
@@ -254,41 +253,64 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
|
||||
// failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
|
||||
// check runs every module directory after one fails too.
|
||||
func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
|
||||
r := &recorder{
|
||||
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
|
||||
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"},
|
||||
tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
|
||||
}
|
||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
if len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("levels %+v, want the one level with both packages' results", a.Levels)
|
||||
}
|
||||
for i, raw := range a.Levels[0].Tests {
|
||||
var res struct{ Passed bool }
|
||||
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
|
||||
t.Errorf("result %d %s, want a failure", i, raw)
|
||||
// Issue 1289: a failure in the packages whose tests changed ends the level there, the others are not run, and no later level
|
||||
// runs. Issue 1921: past the change's own packages every package still runs, so two failing packages none of which the
|
||||
// change touched are two test results of the one level. A Node check does the same with its module directories.
|
||||
func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
changed []string
|
||||
tests map[string]bool
|
||||
want []string // the packages tested, in order
|
||||
}{
|
||||
{"the change's own test fails: nothing else runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b"}},
|
||||
{"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}},
|
||||
{"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}},
|
||||
} {
|
||||
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": row.changed}, tests: row.tests,
|
||||
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"}}
|
||||
if _, ok := row.tests["x/pkg/c"]; ok {
|
||||
r.digests["x/pkg/c"] = "d-c"
|
||||
}
|
||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, c := range r.calls[1:] {
|
||||
if strings.HasPrefix(c, "test ") {
|
||||
var a struct{ Packages []string }
|
||||
_ = json.Unmarshal([]byte(strings.TrimPrefix(c, "test ")), &a)
|
||||
got = append(got, strings.Join(a.Packages, " "))
|
||||
}
|
||||
}
|
||||
// the passing row goes on to the integration level, which tests the same packages again; the first level is what is judged
|
||||
if n := len(row.want); len(got) < n || fmt.Sprint(got[:n]) != fmt.Sprint(row.want) {
|
||||
t.Errorf("%s: tested %v, want %v first", row.name, got, row.want)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
failed := false
|
||||
for _, raw := range a.Levels[0].Tests {
|
||||
var res struct{ Passed bool }
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
failed = failed || !res.Passed
|
||||
}
|
||||
if failed && (len(a.Levels) != 1 || len(got) != len(row.want)) {
|
||||
t.Errorf("%s: a failed level went on: levels %d, tests %v", row.name, len(a.Levels), got)
|
||||
}
|
||||
}
|
||||
|
||||
r = &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
||||
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
||||
tests: map[string]bool{"a": true, "b": false}}
|
||||
s := r.steps()
|
||||
s.Digest = nil
|
||||
out, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
out, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 2 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 {
|
||||
t.Fatalf("calls %v, levels %+v: want the changed directory b alone and no integration level", r.calls, a.Levels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -33,8 +35,17 @@ var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATE
|
||||
// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes
|
||||
// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation
|
||||
// (issue 1790) is the other row beyond, matched by createdAdvisory.
|
||||
//
|
||||
// And the watchman's dead letters and watchdogs (novox/hq ADR 0564 clauses 4, 5, issue 1952 S5): its own consumer on
|
||||
// DEAD_LETTER_NOTICES, a read of DEAD_LETTERS, and the words its watchdogs hear, the controller's jobs until the lease
|
||||
// write.
|
||||
func beyondTheControllers(user string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if strings.HasSuffix(user, "."+WatchmanModule) {
|
||||
for _, s := range append(WatchmanDeadLetterGrants(nil), WatchmanHears()...) {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
node, ok := strings.CutPrefix(user, "node.")
|
||||
if !ok {
|
||||
return out
|
||||
@@ -48,6 +59,19 @@ func beyondTheControllers(user string) map[string]bool {
|
||||
// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not
|
||||
// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5).
|
||||
func mayBeyondTheControllers(user string) map[string]bool {
|
||||
// A runtime's read of its membership from the grants part's state (novox/hq ADR 0571 clause 7): its own key, or the
|
||||
// tool runner's node's.
|
||||
if parts := strings.Split(user, "."); len(parts) == 2 && parts[0] != "node" {
|
||||
key := user
|
||||
if parts[1] == RuntimeModule {
|
||||
key = parts[0] + ".*"
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, s := range readGrants(GrantsMembershipsBucket, key) {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
if _, ok := strings.CutPrefix(user, "node."); !ok {
|
||||
return nil
|
||||
}
|
||||
@@ -65,7 +89,13 @@ func engineBeyond(user, g string) bool {
|
||||
}
|
||||
return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) ||
|
||||
strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) ||
|
||||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".")
|
||||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
|
||||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
|
||||
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
|
||||
// And its own health report (issue 774).
|
||||
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
|
||||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
|
||||
strings.Contains(g, "KV_"+provisions.ProvisionsBucket)
|
||||
}
|
||||
|
||||
// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream
|
||||
@@ -548,6 +578,8 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
|
||||
}
|
||||
}
|
||||
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
|
||||
healthChecks(t)
|
||||
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
|
||||
QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3}))
|
||||
if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy {
|
||||
@@ -555,3 +587,238 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5).
|
||||
func healthChecks(t *testing.T) {
|
||||
t.Helper()
|
||||
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox"),
|
||||
"mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}}
|
||||
for _, c := range []struct {
|
||||
reader, read string
|
||||
may bool
|
||||
}{
|
||||
{"mesh-conditions", ControllerLease, true}, {"mesh-healers", ControllerLease, true}, {WatchmanModule, ControllerLease, true},
|
||||
{"mesh-self-check", ControllerLease, false}, {"messenger", ControllerLease, false}, {ArtifactStoreTools, ControllerLease, true},
|
||||
{"mesh-conditions", ControllerConditions, true}, {"mesh-healers", ControllerConditions, false}, {"messenger", ControllerConditions, false},
|
||||
{"mesh-healers", ControllerHandActs, true}, {"mesh-conditions", ControllerHandActs, false},
|
||||
// ADR 0565 clauses 10, 11 (issue 1953 L5-S4): mesh-hand-acts reads the controller's log beside its own.
|
||||
{"mesh-hand-acts", ControllerHandActs, true},
|
||||
{"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false},
|
||||
} {
|
||||
granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced)
|
||||
if (len(granted) == 1) != c.may {
|
||||
t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may)
|
||||
}
|
||||
}
|
||||
bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} }
|
||||
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
|
||||
{Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")},
|
||||
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts", ControllerConditions}},
|
||||
{Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}, Reads: []string{"restic.good"}},
|
||||
// Issue 1955 L7: the grants part keeps the hearings it withholds; the self-check reads them.
|
||||
{Module: GrantsModule, State: []Bucket{bucket(GrantsModule, GrantsWithheldState)}},
|
||||
{Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}},
|
||||
{Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}, Reads: []string{delivery.NodeHealthRead}},
|
||||
{Module: "mesh-hand-acts", State: []Bucket{bucket("mesh-hand-acts", "warrants")}, Logs: []Log{{Module: "mesh-hand-acts", Name: "acts"}}},
|
||||
// ADR 0570 clauses 1, 8 (issue 1954 L6-P5): mesh-provisioner keeps provisions and plans; the vault and a provider
|
||||
// read them.
|
||||
{Module: "mesh-provisioner", State: []Bucket{bucket("mesh-provisioner", "provisions"), bucket("mesh-provisioner", "plans"),
|
||||
bucket("mesh-provisioner", "registry")}},
|
||||
{Module: "mesh-vault", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
|
||||
{Module: "postgres", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
|
||||
// The node's provisioner client reads its machine's plans and records no needs (ADR 0570 clause 2; its V3).
|
||||
{Module: "mesh-provisioner-client", ProvisionsNode: true},
|
||||
// ADR 0571 clauses 2, 4 (issue 1955): the seat mesh-artifact-sweep says artifacts-gone; the assigner keeps seat-holders.
|
||||
{Module: "artifact-store-tools", Holds: []Seat{{Name: "mesh-artifact-sweep", Emits: []string{"artifacts-gone"}}},
|
||||
Reads: []string{delivery.SeatHoldersRead}},
|
||||
{Module: delivery.AssignerModule, State: []Bucket{bucket(delivery.AssignerModule, delivery.SeatHoldersState)}},
|
||||
// ADR 0571 clause 3: disk-load keeps the readings; the backup holder reads them.
|
||||
{Module: "disk-load", State: []Bucket{bucket("disk-load", "readings")}},
|
||||
{Module: "restic", Reads: []string{"disk-load.readings"}, State: []Bucket{bucket("restic", "good")}},
|
||||
// ADR 0571 clauses 1, 9: the indexer reads the builder's log through its own consumer; the catalogue module asks
|
||||
// the builder's tool.
|
||||
{Module: delivery.BuilderModule, Logs: []Log{{Module: delivery.BuilderModule, Name: delivery.BuilderLog, MaxAgeDays: 30}}},
|
||||
{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead}},
|
||||
{Module: "mesh-catalog", Invokes: []string{delivery.BuilderModule + "." + delivery.BuildsSinceTool}},
|
||||
{Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}},
|
||||
Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}},
|
||||
{Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools",
|
||||
Removes: []string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs}}, {Process: "other"}}},
|
||||
{Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}},
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
may := map[string][]string{
|
||||
"a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"},
|
||||
"a.mesh-provisioner": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
|
||||
"a.mesh-vault": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.postgres-database",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak"},
|
||||
"a.mesh-hand-acts": {"$KV.mesh-hand-acts_warrants.mesh-config.ask-1", "mesh.log.mesh-hand-acts.acts.act-1"},
|
||||
"a.disk-load": {"$KV.disk-load_readings.a.pg.data"},
|
||||
"provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak",
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.a.keycloak",
|
||||
// The needs, for the client before V3, until the lease write (review round 3).
|
||||
"$KV." + ProvisionerNeedsBucket + ".a.keycloak.db.x"},
|
||||
// ADR 0571 clause 3: restic's bundle writes each item's last good night as its own state; the self-check reads it.
|
||||
"a.mesh-self-check": {"$JS.API.DIRECT.GET.KV_restic_good.$KV.restic_good.a.pg.data"},
|
||||
"a.mesh-grants": {"$KV.mesh-grants_withheld.reader.mail.a"},
|
||||
"a.restic": {"$JS.API.DIRECT.GET.KV_disk-load_readings.$KV.disk-load_readings.a.pg.data", "$KV.restic_good.a.pg.data",
|
||||
// ADR 0571 clause 7: its own membership from the grants part's state.
|
||||
"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic"},
|
||||
"a.mesh-delivery-indexer": {"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.r1.mesh.log.mesh-delivery-builder.build-log.letta",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.r1", "$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.r1",
|
||||
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.mesh.log.mesh-delivery-builder.build-log.letta"},
|
||||
"a.mesh-delivery-builder": {"mesh.log.mesh-delivery-builder.build-log.letta"},
|
||||
"a.mesh-catalog": {"mesh.mod.mesh-delivery-builder.tool.builder_builds_since"},
|
||||
WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule,
|
||||
"$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"},
|
||||
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER",
|
||||
"$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.INFO.EVENTS", "mesh.events.dead.EVENTS.a_m"},
|
||||
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
|
||||
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts"},
|
||||
}
|
||||
mayNot := map[string][]string{
|
||||
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
|
||||
"a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"},
|
||||
"a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", "$KV.restic_good.a.pg.data",
|
||||
"$KV.mesh-grants_withheld.reader.mail.a", "$SRV.INFO", "$SRV.PING.node-tools",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-grants_withheld.$KV.mesh-grants_withheld.reader.mail.a"},
|
||||
// Only mesh-provisioner writes provisions and plans, and it writes none of the grants part's states (ADR 0570
|
||||
// clauses 1, 8).
|
||||
"a.mesh-provisioner": {"$KV.mesh-grants_accounts.a.m", "$KV.mesh-grants_user-list.sealed"},
|
||||
"a.mesh-vault": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak",
|
||||
"mesh.seat.mesh-artifact-sweep.event.artifacts-gone"},
|
||||
// Each writes its own state alone, and these users are composed.
|
||||
"a.disk-load": {"$KV.restic_good.a.pg.data"},
|
||||
"a.mesh-delivery-builder": {"$KV.disk-load_readings.a.pg.data"},
|
||||
// Another machine's plans are not its to read, nor another machine's needs, which it keeps for the client before
|
||||
// V3 until the lease write.
|
||||
"provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.b.keycloak",
|
||||
"$KV.mesh-provisioner_plans.a.keycloak", "$KV." + ProvisionerNeedsBucket + ".b.keycloak.db.x"},
|
||||
// Only the assigner writes seat-holders.
|
||||
"a.artifact-store-tools": {"$KV.mesh-delivery-assigner_seat-holders.mesh-broker"},
|
||||
// Only disk-load writes its readings.
|
||||
"a.restic": {"$KV.disk-load_readings.a.pg.data"},
|
||||
"a.postgres": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
|
||||
// Nor mesh-hand-acts' log and warrants, which it alone writes (ADR 0565 clause 10).
|
||||
"a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch",
|
||||
"$KV.mesh-hand-acts_warrants.x", "mesh.log.mesh-hand-acts.acts.x"},
|
||||
// The watchman never deletes or changes the controller's consumer (clause 5).
|
||||
// Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's
|
||||
// (the lead, 2026-10-29).
|
||||
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.KV_mesh-vault_bus-accounts", "$JS.API.STREAM.MSG.GET.KV_x",
|
||||
"$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER",
|
||||
// In L4 it neither delivers again nor drops (clause 5, the lead 2026-10-29).
|
||||
"$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x",
|
||||
"mesh.again.a_m.x"},
|
||||
WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller",
|
||||
"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule},
|
||||
// The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts
|
||||
// remove nothing; an account is granted the removals its bus declares and no other (the catalogue refuses them
|
||||
// outside the bus module).
|
||||
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
|
||||
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts"},
|
||||
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
||||
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
||||
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x"},
|
||||
}
|
||||
// ADR 0571 clause 1: a read naming another module's log is issued to the reader as a log it reads, never writes.
|
||||
if m := MembershipFor("a", Declared{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead, "x.y"}},
|
||||
Placements{Logs: map[string]bool{delivery.BuildLogRead: true}}); len(m.Logs) != 1 || m.Logs[0].Writes ||
|
||||
m.Logs[0].Stream != "LOG_mesh-delivery-builder_build-log" || m.Logs[0].Name != delivery.BuildLogRead {
|
||||
t.Errorf("the indexer's membership issues the logs %+v", m.Logs)
|
||||
}
|
||||
// ADR 0565 clauses 6, 7 (issue 1953 L5-S4): a node's engine writes its own packages key and no other node's; only the
|
||||
// assigner writes its policies.
|
||||
for _, u := range users {
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// ADR 0570 clause 6: a node's engine reads its own node's provision keys, and no other node's, and writes none.
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.db") ||
|
||||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.b.keycloak.db") ||
|
||||
MayPublish(perms, "$KV.mesh-provisioner_provisions.a.keycloak.db")) {
|
||||
t.Errorf("node %s reads the provisions %v", u.Node, perms.Publish)
|
||||
}
|
||||
// Issue 774: a node's engine writes its own health report's key and no other node's.
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_health.a") || MayPublish(perms, "$KV.mesh-host_health.b")) {
|
||||
t.Errorf("node %s writes the health reports %v", u.Node, perms.Publish)
|
||||
}
|
||||
if u.Kind == KindModule && u.Module == WatchmanModule && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b") ||
|
||||
MayPublish(perms, "$KV.mesh-host_health.b")) {
|
||||
t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish)
|
||||
}
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) {
|
||||
t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish)
|
||||
}
|
||||
if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") {
|
||||
t.Errorf("%s writes the assigner's policies", u.Username())
|
||||
}
|
||||
}
|
||||
// 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as
|
||||
// long as the log (ADR 0297).
|
||||
if aged, kept := (Log{Module: "mesh-hand-acts", Name: "acts", MaxAgeDays: 400}).Config(), (Log{Module: "m", Name: "l"}).Config(); aged.MaxAge != 400*24*time.Hour || kept.MaxAge != 0 || !aged.DenyDelete {
|
||||
t.Errorf("an aged log's stream keeps %s, an unaged one %s", aged.MaxAge, kept.MaxAge)
|
||||
}
|
||||
// The tools account's secret is the module's own broker-<binary>, which its manifest's own-secrets names and the
|
||||
// vault keeps by that name (AccountSecret).
|
||||
if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools",
|
||||
URL: "nats://a:4222"}); w.Valid() != nil || w.OwnSecret() != "broker-nats-tools" {
|
||||
t.Errorf("the tools account is held in %q (%v)", w.OwnSecret(), w.Valid())
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, u := range users {
|
||||
name := u.Username()
|
||||
if u.Module == WatchmanModule && u.Kind == KindModule {
|
||||
name = WatchmanModule
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if name == WatchmanModule {
|
||||
// It hears its watchdogs' words on core subjects (Limits 3, 11).
|
||||
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
|
||||
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
|
||||
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
|
||||
if !MaySubscribe(perms, subject) {
|
||||
t.Errorf("the watchman may not hear %s", subject)
|
||||
}
|
||||
}
|
||||
// Never the bus's API audit, which carries every API answer in the account (the lead, 2026-10-29).
|
||||
if MaySubscribe(perms, "$JS.EVENT.ADVISORY.API") || MaySubscribe(perms, "$JS.EVENT.ADVISORY.API.x") {
|
||||
t.Error("the watchman hears the bus's API audit")
|
||||
}
|
||||
if !MayPublish(perms, "$JS.API.DIRECT.GET.PROV_APPLIED.prov.applied.g14.keycloak") {
|
||||
t.Error("the watchman may not read the newest provisioner report")
|
||||
}
|
||||
}
|
||||
subjects := append(append([]string(nil), may[name]...), may[name+" sources"]...)
|
||||
denied := append(append([]string(nil), mayNot[name]...), mayNot[name+" sources"]...)
|
||||
if len(may[name+" sources"]) > 0 {
|
||||
seen[name+" sources"] = true
|
||||
}
|
||||
for _, subject := range subjects {
|
||||
seen[name] = true
|
||||
if !MayPublish(perms, subject) {
|
||||
t.Errorf("%s may not publish %s", name, subject)
|
||||
}
|
||||
}
|
||||
for _, subject := range denied {
|
||||
seen[name] = true
|
||||
if MayPublish(perms, subject) {
|
||||
t.Errorf("%s may publish %s", name, subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every row names a user that was composed, in either table: a row for a user absent would pass for nothing.
|
||||
for _, table := range []map[string][]string{may, mayNot} {
|
||||
for name := range table {
|
||||
if !seen[name] {
|
||||
t.Errorf("no user %s was composed", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package buscompose
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module
|
||||
@@ -22,6 +24,16 @@ const (
|
||||
// EngineActsBucket is that state's bucket.
|
||||
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
|
||||
|
||||
// EnginePackagesBucket is the engine's state of its node's declared packages (novox/hq ADR 0565 clause 6): one key, the
|
||||
// node's name, written by that node's engine alone.
|
||||
var EnginePackagesBucket = BucketName(EngineModule, delivery.EnginePackagesState)
|
||||
|
||||
// EnginePackagesGrants is what a node's engine publishes to keep its node's key of the packages bucket: the key alone,
|
||||
// and its read.
|
||||
func EnginePackagesGrants(node string) []string {
|
||||
return append([]string{"$KV." + EnginePackagesBucket + "." + node}, readGrants(EnginePackagesBucket, node)...)
|
||||
}
|
||||
|
||||
// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it
|
||||
// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the
|
||||
// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR
|
||||
@@ -41,5 +53,20 @@ func engineGrants(p Principal) (pub, sub []string) {
|
||||
if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil {
|
||||
pub = append(pub, own...)
|
||||
}
|
||||
// And the provisions its node's modules consume, its own node's keys alone, read (novox/hq ADR 0570 clause 6: the
|
||||
// consuming engine checks the vault's name and generation of what it is handed).
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, readGrants(provisions.ProvisionsBucket, p.Node+".>")...)
|
||||
}
|
||||
// And its node's own health report, its key `<node>` alone (issue 774, the operator's alert table): what the watchman
|
||||
// reads for the machine's alerts.
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, append([]string{"$KV." + delivery.NodeHealthBucket + "." + p.Node},
|
||||
readGrants(delivery.NodeHealthBucket, p.Node)...)...)
|
||||
}
|
||||
// And its node's declared packages, its own key alone (ADR 0565 clause 6).
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, EnginePackagesGrants(p.Node)...)
|
||||
}
|
||||
return append(pub, tp...), append(sub, ts...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5).
|
||||
//
|
||||
// - The condition keeper reads the controller's open conditions until the lease write, for what clauses 8 and 9 carry
|
||||
// from them, and mesh-healers the controller's hand-act log, for S15 (Limits 2): both buckets the controller
|
||||
// announces readable by nobody, so each is read by its named reader alone. The keeper, mesh-healers and the
|
||||
// watchman read the controller's lease (Limits 8). Nobody reads mesh-healers' `would` (clause 8): what the healers
|
||||
// would have done reaches no condition. Every observer's `findings`, the healers' `attempts`, the wants and the
|
||||
// haves are read through `reads` as any announced state is; and no module writes another's state, so an observer
|
||||
// can never write the keeper's buckets (clause 2).
|
||||
// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the
|
||||
// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5). It
|
||||
// lists and shows DEAD_LETTERS, which is all it does before the lease write.
|
||||
// - The bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes`
|
||||
// (catalogue.BusAccounts), which may remove only the consumer `controller` on DEAD_LETTER_NOTICES and keys of the
|
||||
// node-engine's acts bucket (Limits 4), never the backup's account `broker`, which only reads (ADR 0235).
|
||||
|
||||
// The states the composer names its readers for, beside the lease (NamedReaders).
|
||||
var (
|
||||
// ControllerConditions is the controller's open conditions, read by the keeper alone (ADR 0564 clauses 8, 9).
|
||||
ControllerConditions = ControllerSeat + ".conditions"
|
||||
// ControllerHandActs is the controller's hand-act log, read by mesh-healers alone of the four health holders (ADR
|
||||
// 0564 Limits 2).
|
||||
ControllerHandActs = ControllerSeat + ".hand-acts"
|
||||
// HealersWould is what mesh-healers would have done while the lease names a controller: read by nobody (clause 8).
|
||||
HealersWould = health.HealersModule + "." + health.WouldState
|
||||
)
|
||||
|
||||
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, issue 1952
|
||||
// notes 6 and 7), each removal its grant: the controller's consumer on DEAD_LETTER_NOTICES read and deleted (the lease
|
||||
// write's step, clause 5); the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
|
||||
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills. A
|
||||
// removal outside the set is refused, as the catalogue refuses it (catalogue.BusRemovals).
|
||||
func BusRemovalGrants(removes []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, r := range removes {
|
||||
switch r {
|
||||
case catalogue.RemovesControllerDeadLetterConsumer:
|
||||
out = append(out, "$JS.API.CONSUMER.INFO."+DeadLetterNoticesStream+"."+ControllerName,
|
||||
"$JS.API.CONSUMER.DELETE."+DeadLetterNoticesStream+"."+ControllerName)
|
||||
case catalogue.RemovesEngineActs:
|
||||
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
|
||||
default:
|
||||
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4)", r,
|
||||
strings.Join(catalogue.BusRemovals, ", "))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removesEngineActs is an account declared to remove keys of the node-engine's acts bucket: the writers table's one
|
||||
// exemption from a node's engine being the only writer of its keys, for removal (ADR 0564 Limits 4).
|
||||
func removesEngineActs(p Principal, pattern string) bool {
|
||||
return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesEngineActs) && pattern == "$KV."+EngineActsBucket+".>"
|
||||
}
|
||||
|
||||
// WatchmanDeadLetterConsumer is the watchman's own consumer on DEAD_LETTER_NOTICES, made only once the lease names no
|
||||
// controller and `controller` is gone (ADR 0564 clause 5).
|
||||
const WatchmanDeadLetterConsumer = WatchmanModule
|
||||
|
||||
// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters in L4 (ADR 0564 clause 5), at least
|
||||
// privilege: its own pulled consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged); whether `controller`
|
||||
// still stands (read only); a read of DEAD_LETTERS (its information and one kept message); a copy kept
|
||||
// (`mesh.events.dead.>`); and on each stream a notice can name (sources: EVENTS, the seats' work queues and the
|
||||
// modules' own), its information, for a source's age (ADR 0376), and the message given up on, each stream by its name.
|
||||
// Never a wildcard over the streams and never a bucket's or an object store's stream: a read of any message would read
|
||||
// every bucket (the lead, 2026-10-29, on issue 1952 S5). Delivering again and dropping stay refused in L4, so it may
|
||||
// neither delete a message nor publish `mesh.again.>`.
|
||||
func WatchmanDeadLetterGrants(sources []string) []string {
|
||||
own := DeadLetterNoticesStream + "." + WatchmanDeadLetterConsumer
|
||||
out := []string{
|
||||
"$JS.API.STREAM.INFO." + DeadLetterNoticesStream,
|
||||
"$JS.API.CONSUMER.INFO." + DeadLetterNoticesStream + "." + ControllerName,
|
||||
"$JS.API.CONSUMER.CREATE." + own,
|
||||
"$JS.API.CONSUMER.INFO." + own,
|
||||
"$JS.API.CONSUMER.MSG.NEXT." + own,
|
||||
"$JS.ACK." + own + ".>",
|
||||
"$JS.API.STREAM.INFO." + DeadLettersStream,
|
||||
"$JS.API.STREAM.MSG.GET." + DeadLettersStream,
|
||||
deadLetterPrefix + ">",
|
||||
// And the provisioner clients' reports, the newest per subject read at its start (S21, ADR 0362 §3).
|
||||
"$JS.API.STREAM.INFO." + ProvAppliedStream,
|
||||
"$JS.API.STREAM.MSG.GET." + ProvAppliedStream,
|
||||
"$JS.API.DIRECT.GET." + ProvAppliedStream + ".>",
|
||||
}
|
||||
for _, s := range DeadLetterSources(sources) {
|
||||
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.MSG.GET."+s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
|
||||
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
|
||||
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
|
||||
// connections (S9), the provisioner clients' reports (S21), and the dead letters kept. Never the bus's API audit
|
||||
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
|
||||
// them (the lead, 2026-10-29).
|
||||
func WatchmanHears() []string {
|
||||
return []string{
|
||||
"mesh.mod.power.event.*",
|
||||
"mesh.mod.*.event.provisioner.failing",
|
||||
"mesh.mod.*.event.provisioner.recovered",
|
||||
"mesh.control.*.tools-alive",
|
||||
"mesh.seat.mesh-self-check.event." + health.EventChecked,
|
||||
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
|
||||
"mesh.mod.nats.event.bus.slow-consumer",
|
||||
"mesh.mod.nats.event.bus.connection-closed",
|
||||
ProvAppliedSubjects,
|
||||
deadLetterPrefix + ">",
|
||||
}
|
||||
}
|
||||
|
||||
// DeadLetterSources are the streams of names a dead letter may come from, sorted, none twice: each a plain name, never a
|
||||
// bucket's (KV_) or an object store's (OBJ_) stream, the dead letters' own two streams, or a pattern.
|
||||
func DeadLetterSources(names []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
switch {
|
||||
case seen[n], !safeSubject.MatchString(n), strings.HasPrefix(n, "KV_"), strings.HasPrefix(n, "OBJ_"),
|
||||
n == DeadLettersStream, n == DeadLetterNoticesStream:
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// deadLetterSourcesOf are the streams the records' consumers give up on: EVENTS, the work queue of every seat that
|
||||
// accepts work, and every module's and process's own work queue.
|
||||
func deadLetterSourcesOf(r Records) []string {
|
||||
out := []string{EventsStream}
|
||||
for _, mods := range r.Assigned {
|
||||
for _, d := range mods {
|
||||
for _, s := range append(append([]Seat(nil), d.Holds...), d.Uses...) {
|
||||
if len(s.Accepts) > 0 {
|
||||
out = append(out, seatStreamName(s.Name))
|
||||
}
|
||||
}
|
||||
for _, q := range d.Queues {
|
||||
out = append(out, q.Stream)
|
||||
}
|
||||
for _, pr := range d.Processes {
|
||||
for _, q := range pr.Queues {
|
||||
out = append(out, q.Stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return DeadLetterSources(out)
|
||||
}
|
||||
|
||||
// keepsDeadLetters is the watchman keeping the dead letters: a second writer of the copies beside the controller, which
|
||||
// keeps them until its lease is handed away and the watchman after (ADR 0564 clause 5). Delivering again stays the
|
||||
// controller's in L4.
|
||||
func keepsDeadLetters(p Principal, pattern string) bool {
|
||||
return isController(p, pattern) ||
|
||||
(p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">")
|
||||
}
|
||||
+62
-4
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -14,8 +15,10 @@ import (
|
||||
// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives
|
||||
// its stream and subjects, the controller creates it from the catalogue on every raise and from
|
||||
// registration, never a module, and **no path of the mesh removes one** — a log whose declaration
|
||||
// is gone is reported, as a bucket is. Unlike a bucket, a log is its owner's alone: no other module
|
||||
// reads it, so there is no read of a log to grant or issue.
|
||||
// is gone is reported, as a bucket is. Only its owner appends to it. Another module reads it when its manifest's
|
||||
// `reads` names it, `<module>.<log>` (novox/hq ADR 0571 clause 1; the lead, 2026-10-29): by direct gets, and through an
|
||||
// ordered consumer of its own it creates and lets go of to read from a time or the newest per key and then follow new
|
||||
// entries; never a durable consumer, never a write.
|
||||
//
|
||||
// Pure, but for the stream's configuration, which is the server's own type so that what is tested
|
||||
// is exactly what is sent; jetstream.go is the part that asks a server.
|
||||
@@ -37,6 +40,8 @@ type Log struct {
|
||||
Name string
|
||||
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
|
||||
MaxMiB int
|
||||
// MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration).
|
||||
MaxAgeDays int
|
||||
}
|
||||
|
||||
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
|
||||
@@ -74,7 +79,7 @@ func (l Log) Why() string {
|
||||
}
|
||||
|
||||
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
|
||||
// limits, with no maximum age and no cap per key, whose message is an entry's value and 4 KiB of
|
||||
// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
|
||||
// headers at most, that refuses a new entry when full rather than
|
||||
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
|
||||
// is how the runtime reads it.
|
||||
@@ -85,7 +90,7 @@ func (l Log) Config() jetstream.StreamConfig {
|
||||
Subjects: []string{l.Subject() + ".>"},
|
||||
Storage: jetstream.FileStorage,
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
MaxAge: 0,
|
||||
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
|
||||
MaxMsgs: -1,
|
||||
MaxMsgsPerSubject: -1,
|
||||
MaxBytes: l.MaxBytes(),
|
||||
@@ -135,6 +140,59 @@ func logGrants(module string, names []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeclaredLogs is every log a module placed anywhere declares, as a read names it: `<module>.<log>`.
|
||||
func DeclaredLogs(r Records) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, ds := range r.Assigned {
|
||||
for _, d := range ds {
|
||||
for _, l := range d.Logs {
|
||||
out[d.Module+"."+l.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withReadLogs is d with ReadLogs set: of its reads, those naming a log of logs.
|
||||
func withReadLogs(d Declared, logs map[string]bool) Declared {
|
||||
d.ReadLogs = nil
|
||||
for _, r := range d.Reads {
|
||||
if logs[strings.TrimSpace(r)] {
|
||||
d.ReadLogs = append(d.ReadLogs, strings.TrimSpace(r))
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// readLogGrants is what a reader of other modules' logs publishes, for each `<module>.<log>` read: the stream's info,
|
||||
// direct gets (an entry by sequence, the last of a key), and an ordered consumer of its own (created with any name and
|
||||
// filter, pulled, read and deleted). No append, no purge, no delete of an entry: the log's stream refuses those anyway.
|
||||
func readLogGrants(reads []string) []string {
|
||||
var out []string
|
||||
for _, r := range reads {
|
||||
module, name, ok := strings.Cut(r, ".")
|
||||
if !ok || !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
stream := LogStreamName(module, name)
|
||||
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream, "$JS.API.DIRECT.GET."+stream+".>",
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".>", "$JS.API.CONSUMER.MSG.NEXT."+stream+".*",
|
||||
"$JS.API.CONSUMER.INFO."+stream+".*", "$JS.API.CONSUMER.DELETE."+stream+".*")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readLogsIssuedFor is every other module's log a module's code reads, as its membership lists them: by the read's
|
||||
// name, never written.
|
||||
func readLogsIssuedFor(d Declared) []LogIssued {
|
||||
var out []LogIssued
|
||||
for _, r := range d.ReadLogs {
|
||||
module, name, _ := strings.Cut(r, ".")
|
||||
out = append(out, LogIssued{Name: r, Stream: LogStreamName(module, name), Subject: LogSubject(module, name)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logNames is the local names of a module's logs.
|
||||
func logNames(logs []Log) []string {
|
||||
out := make([]string, 0, len(logs))
|
||||
|
||||
@@ -24,6 +24,9 @@ type Placements struct {
|
||||
Interchangeable map[string]bool
|
||||
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
|
||||
Kinds []KindHeld
|
||||
// Logs is every log a module placed anywhere declares, `<module>.<log>` (DeclaredLogs): a read naming one is issued
|
||||
// as a log read (novox/hq ADR 0571 clause 1).
|
||||
Logs map[string]bool
|
||||
}
|
||||
|
||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
||||
@@ -58,7 +61,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
}
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
m.Logs = logsIssuedFor(d)
|
||||
m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...)
|
||||
m.Streams = streamsIssuedFor(d)
|
||||
m.Hears = HeardIssuedFor(node, d)
|
||||
m.Waits = waitsIssuedFor(d)
|
||||
@@ -103,7 +106,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
|
||||
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
|
||||
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
|
||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable, Logs: DeclaredLogs(r)}
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
|
||||
|
||||
@@ -181,6 +181,8 @@ type Principal struct {
|
||||
KeyedReads []KeyedRead
|
||||
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
||||
Logs []string
|
||||
// ReadLogs are, of Reads, those naming another module's log (novox/hq ADR 0571 clause 1).
|
||||
ReadLogs []string
|
||||
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
|
||||
// subjects it was granted (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
@@ -196,6 +198,12 @@ type Principal struct {
|
||||
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
|
||||
// else — not its declarations, which the node's tool runtime serves for it.
|
||||
SnapshotsTheBus bool
|
||||
// Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus
|
||||
// (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them.
|
||||
Removes []string
|
||||
// DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name
|
||||
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
|
||||
DeadLetterSources []string
|
||||
|
||||
// WitnessesController is a machine principal whose node-engine witnesses the controller's upgrades:
|
||||
// the machine runs the controller (novox/hq ADR 0236, lease/witness.go). It may read the lease's one
|
||||
@@ -655,6 +663,19 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil
|
||||
}
|
||||
|
||||
if p.Kind == KindProcess && len(p.Removes) > 0 {
|
||||
pub, err := BusRemovalGrants(p.Removes)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
sort.Strings(pub)
|
||||
if err := CheckWriters(p, pub); err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
// Its own inbox for the answers, and nothing else (Limits 4).
|
||||
return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil
|
||||
}
|
||||
|
||||
var pub, sub []string
|
||||
switch p.Kind {
|
||||
case KindController:
|
||||
@@ -988,6 +1009,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if p.Module == WatchmanModule {
|
||||
sub = append(sub, BusConnectSubject, BusDisconnectSubject)
|
||||
pub = append(pub, BusConnzSubject)
|
||||
// And the dead letters, from its own consumer, never the controller's (novox/hq ADR 0564 clause 5).
|
||||
pub = append(pub, WatchmanDeadLetterGrants(p.DeadLetterSources)...)
|
||||
// And the words its watchdogs hear (ADR 0564 clause 4, Limits 3, 11).
|
||||
sub = append(sub, WatchmanHears()...)
|
||||
}
|
||||
// The grants part alone publishes the modules' memberships into the assignments stream (novox/hq ADR 0547 §5;
|
||||
// the operator, 2026-10-25 22:31): one per subject, `mesh.assignment.<node>.<module>`, reading the last one of each
|
||||
@@ -1040,6 +1065,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// directly from the stream and followed live. Nothing else's.
|
||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+MembershipSubject(p.Node, p.Module))
|
||||
// And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): the read that replaces
|
||||
// the stream's at the lease write, when the grants part stops publishing there.
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...)
|
||||
|
||||
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
|
||||
// grant a person gets and derived the same way, so "what may this module ask" is
|
||||
@@ -1157,6 +1185,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
sub = append(sub, stateCreatedHeard(kept)...)
|
||||
// And its logs (novox/hq ADR 0297): appended to and read directly, its own alone.
|
||||
pub = append(pub, logGrants(p.Module, p.Logs)...)
|
||||
pub = append(pub, readLogGrants(p.ReadLogs)...)
|
||||
|
||||
// 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records
|
||||
// (novox/hq ADR 0259 §3).
|
||||
@@ -1204,6 +1233,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// that varies is the module, so the pattern is the machine's own assignments.
|
||||
sub = append(sub, "mesh.assignment."+p.Node+".*")
|
||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+p.Node+".*")
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...)
|
||||
// And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's
|
||||
// name and no other's, on core NATS like the host's.
|
||||
pub = append(pub, "mesh.control."+p.Node+".tools-alive")
|
||||
@@ -1260,6 +1290,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// runtime's to keep, from the logs each membership lists.
|
||||
for _, d := range p.Carries {
|
||||
pub = append(pub, logGrants(d.Module, logNames(d.Logs))...)
|
||||
pub = append(pub, readLogGrants(d.ReadLogs)...)
|
||||
}
|
||||
// **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own,
|
||||
// named one by one in its manifest, as a plain subscription — no consumer is made for them, and no
|
||||
@@ -1705,6 +1736,18 @@ func CreateConsumerGrants(consumers []ModuleConsumer) []string {
|
||||
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
|
||||
const GrantsModule = "mesh-grants"
|
||||
|
||||
// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `<node>.<module>`, its
|
||||
// membership (Membership) as the assignments stream carries it, written only when it differs. A module's runtime reads
|
||||
// its own key, the tool runner its node's; the grants part alone writes it.
|
||||
const (
|
||||
GrantsMembershipsState = "memberships"
|
||||
GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState
|
||||
GrantsMembershipsRead = GrantsModule + "." + GrantsMembershipsState
|
||||
)
|
||||
|
||||
// MembershipKey is an assignment's key in the grants part's memberships: `<node>.<module>`.
|
||||
func MembershipKey(node, module string) string { return node + "." + module }
|
||||
|
||||
// The bus's own events, imported into MESH from the default system account `$SYS` under subjects of MESH's own
|
||||
// (novox/hq ADR 0431 §1, issue 1147): the server says an account's connections opening and closing only to the system
|
||||
// account, and the list of an account's connections only to a request made there. `$SYS` holds no user, so nobody
|
||||
|
||||
@@ -3,9 +3,11 @@ package buscompose
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -34,9 +36,10 @@ var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
|
||||
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
|
||||
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
|
||||
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
|
||||
// mesh-config's resolved values. **Named one by one**, as the controller's asks are: a domain that moves to the
|
||||
// mesh-config's resolved values; and the seat mesh-provisioner's plans of its machine, `<node>.<module>` (novox/hq ADR
|
||||
// 0570 clause 2). **Named one by one**, as the controller's asks are: a domain that moves to the
|
||||
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
|
||||
var ProvisionerReads = []string{ConfigResolvedBucket}
|
||||
var ProvisionerReads = []string{ConfigResolvedBucket, provisions.PlansBucket}
|
||||
|
||||
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
|
||||
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
|
||||
@@ -68,8 +71,13 @@ var (
|
||||
)
|
||||
|
||||
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
|
||||
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A
|
||||
// client cannot write another machine's needs, or say another machine applied a file.
|
||||
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A client
|
||||
// cannot write another machine's needs, or say another machine applied a file.
|
||||
//
|
||||
// **The needs stay granted for one release, and are removed after the lease write** (review round 3 of design 47's
|
||||
// release): the client's V3 reads its machine's plans from the seat mesh-provisioner's `plans` (novox/hq ADR 0570 clause
|
||||
// 2) and records no needs, but the client before it still runs on a node until its delivery reaches it, and it writes
|
||||
// them.
|
||||
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
|
||||
|
||||
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
|
||||
|
||||
@@ -3,8 +3,11 @@ package buscompose
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945). The grants part writes
|
||||
@@ -77,12 +80,22 @@ func grantsOwnObjects(p Principal, pattern string) bool {
|
||||
// readers too (announce.State.Readers); both are honoured.
|
||||
const VaultBusAccounts = "mesh-vault.bus-accounts"
|
||||
|
||||
// ArtifactStoreTools is the module holding the seat mesh-artifact-sweep (novox/hq ADR 0571 clause 2).
|
||||
const ArtifactStoreTools = "artifact-store-tools"
|
||||
|
||||
// NamedReaders are the states only the modules named may read, by `<owner>.<name>`: the grants part's wanted accounts
|
||||
// are read by the vault alone (novox/hq ADR 0561 §2).
|
||||
// The controller's lease is read by the parts that turn on at its write (ADR 0561 §3, §6), and by the assigner, which
|
||||
// reads the catalogue's placement alone from then (ADR 0549 §5, issue 774).
|
||||
//
|
||||
// The health holders (novox/hq ADR 0564 Limits 2 and 8, clauses 8 and 9): the condition keeper, mesh-healers and the
|
||||
// watchman read the lease; the keeper alone the controller's conditions; mesh-healers and mesh-hand-acts its hand-act log
|
||||
// (ADR 0565 clauses 10, 11: every reader of acts reads both logs); and nobody the healers' `would` (health_grants.go).
|
||||
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat},
|
||||
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule}}
|
||||
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule, health.KeeperModule, health.HealersModule, WatchmanModule,
|
||||
// ADR 0571 clause 2: the artifact store's seat sweeps only once the lease names no controller.
|
||||
ArtifactStoreTools},
|
||||
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {}}
|
||||
|
||||
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
||||
// that names none.
|
||||
@@ -113,3 +126,30 @@ func withNamedReaders(d Declared) Declared {
|
||||
d.ReadsRefused = append(append([]string(nil), d.ReadsRefused...), refused...)
|
||||
return d
|
||||
}
|
||||
|
||||
// **The hearings the grants part withholds, on the bus** (issue 1955 L7, mesh-delivery e564eaf): a hearing of a restricted
|
||||
// stream a module asks in the tool runner, withheld because only an account of its own may hear it (ADR 0376 §2), kept
|
||||
// in the grants part's state `withheld`, one key `<module>.<stream, lower case>.<machine>` (the controller's condition id
|
||||
// of it), written on change only and deleted once heard; the grants part's tools read it (D-heard was dropped on
|
||||
// 2026-10-30, so no other module reads it).
|
||||
const (
|
||||
GrantsWithheldState = "withheld"
|
||||
GrantsWithheldBucket = GrantsModule + "_" + GrantsWithheldState
|
||||
GrantsWithheldRead = GrantsModule + "." + GrantsWithheldState
|
||||
WithheldVersion = 1
|
||||
)
|
||||
|
||||
// WithheldHearing is one withheld hearing: the machine, the module, the restricted stream and its emitter, the plain
|
||||
// words of it and the remedy (Withheld.Said), and when the grants part first wrote it as it stands.
|
||||
type WithheldHearing struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Stream string `json:"stream"`
|
||||
Emitter string `json:"emitter"`
|
||||
Said string `json:"said"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// WithheldKey is a withheld hearing's key: `<module>.<stream, lower case>.<machine>`.
|
||||
func WithheldKey(w Withheld) string { return w.Module + "." + strings.ToLower(w.Stream) + "." + w.Node }
|
||||
|
||||
+14
-4
@@ -64,6 +64,9 @@ type Declared struct {
|
||||
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
|
||||
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
|
||||
ReadsRefused []string
|
||||
// ReadLogs are, of its reads, those naming another module's log (`<module>.<log>`, novox/hq ADR 0571 clause 1): filled
|
||||
// by Users and PlacementsOf from the logs the modules placed anywhere declare, never by a caller.
|
||||
ReadLogs []string
|
||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
|
||||
KeyedReads []KeyedRead
|
||||
@@ -105,6 +108,9 @@ type ProcessPrincipal struct {
|
||||
Emits []string
|
||||
// Queues are the module's own work-queue streams this process is the one consumer of.
|
||||
Queues []QueueOwner
|
||||
// Removes are what its account may remove from the bus, as its bus declares them (catalogue.BusRemovals; novox/hq
|
||||
// ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants.
|
||||
Removes []string
|
||||
// State is the module's own state, by local name, this process reads and writes.
|
||||
State []string
|
||||
// Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on
|
||||
@@ -154,6 +160,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
|
||||
|
||||
served := servedReadsOf(r)
|
||||
logs := DeclaredLogs(r)
|
||||
grants := -1
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
@@ -195,7 +202,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
// A state with named readers is read by them alone (ADR 0547 clause 9): a read named for another module is
|
||||
// granted nothing, here and in the runtime carrying the module.
|
||||
d = withNamedReaders(d)
|
||||
d = withReadLogs(withNamedReaders(d), logs)
|
||||
// **A process account is its own, delivered in its own secret** (broker-<process id>, novox/hq
|
||||
// ADR 0436 §1, §2), never the module's `broker` — so it is composed whether or not the module
|
||||
// itself has an account, and is never carried by the machine's runtime, which runs as the
|
||||
@@ -204,7 +211,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
out = append(out, Principal{
|
||||
Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process,
|
||||
Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks,
|
||||
Invokes: pr.Invokes,
|
||||
Invokes: pr.Invokes, Removes: pr.Removes,
|
||||
})
|
||||
}
|
||||
if runtimeHere && d.Module == RuntimeModule {
|
||||
@@ -223,12 +230,15 @@ func Users(r Records) ([]Principal, error) {
|
||||
Kind: KindModule, Node: node, Module: d.Module,
|
||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
|
||||
})
|
||||
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
|
||||
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
|
||||
if d.Module == WatchmanModule {
|
||||
out[len(out)-1].DeadLetterSources = deadLetterSourcesOf(r)
|
||||
}
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
grants = len(out) - 1
|
||||
@@ -244,7 +254,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
var carried []Declared
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs == "" && runnerServes(d) {
|
||||
d = withNamedReaders(d)
|
||||
d = withReadLogs(withNamedReaders(d), logs)
|
||||
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
|
||||
d.Hears = ComposedHears(d)
|
||||
carried = append(carried, d)
|
||||
|
||||
@@ -220,7 +220,8 @@ var WritersTable = []WriterRow{
|
||||
// and delivered again or dropped only through its verb, with why.
|
||||
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
||||
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
|
||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters,
|
||||
Shared: "the watchman keeps the copies from the controller's lease write, the controller before it; delivering again stays the controller's (novox/hq ADR 0564 clause 5)"},
|
||||
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
|
||||
// anything else write them is refused at composition. Built so far: the assignments and each registered
|
||||
// module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).
|
||||
@@ -247,9 +248,21 @@ var WritersTable = []WriterRow{
|
||||
Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys},
|
||||
// The acts a node's engine was asked through its node seat, and its own asks (novox/hq ADR 0563 §2, §3): the
|
||||
// node-engine's declared state, created by the grants part, written by each node's engine under its own keys.
|
||||
// A node's own health report (issue 774): its engine's own key, read by the watchman.
|
||||
{State: "a machine's health report", Writer: "the node-engine of the machine, its key `<node>` alone",
|
||||
KeptIn: "key-value " + delivery.NodeHealthBucket, Others: "read by the watchman through `reads`",
|
||||
Subjects: kvOf(delivery.NodeHealthBucket), Writes: ownNodeKeys},
|
||||
// A node's declared packages (novox/hq ADR 0565 clause 6): its engine's own key, read by pacman's verbs.
|
||||
{State: "a machine's declared packages", Writer: "the node-engine of the machine, its key `<node>` alone",
|
||||
KeptIn: "key-value " + EnginePackagesBucket, Others: "read by pacman through `reads`",
|
||||
Subjects: kvOf(EnginePackagesBucket), Writes: ownNodeKeys},
|
||||
{State: "a machine's hand acts", Writer: "the node-engine of the machine, under its own keys `<node>.>`, compare-and-set",
|
||||
KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts",
|
||||
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), Writes: ownNodeKeys},
|
||||
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket),
|
||||
Writes: func(p Principal, pattern string) bool {
|
||||
return ownNodeKeys(p, pattern) || removesEngineActs(p, pattern)
|
||||
},
|
||||
Shared: "the bus module's tools account removes keys of a full bucket, for removal only (novox/hq ADR 0564 Limits 4)"},
|
||||
// The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each
|
||||
// object under the digest of its content; a node-engine reads one by its name, with no list and no write.
|
||||
{State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " +
|
||||
|
||||
+30
-18
@@ -126,23 +126,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
continue
|
||||
}
|
||||
made := by[a.Name]
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
//
|
||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||
// A process the host runs is the module's service, not its tools: the controller declares
|
||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
loads = []string{bin}
|
||||
}
|
||||
}
|
||||
loads := BundleLoads(m, a, run)
|
||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
||||
// it reached it by, and a manifest carrying that address names an installation —
|
||||
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
||||
@@ -152,7 +136,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
||||
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
||||
Binary: BinaryOf(a),
|
||||
Binary: BinaryOf(a), Bus: a.Bus,
|
||||
})
|
||||
}
|
||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||
@@ -715,3 +699,31 @@ func undeliveredBundles(m Manifest) []string {
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
|
||||
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
|
||||
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
|
||||
// module composed as what runs of it reads again when the runtime already dropped it (issue 1126).
|
||||
func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
//
|
||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||
// A process the host runs is the module's service, not its tools: the controller declares
|
||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
loads = []string{bin}
|
||||
}
|
||||
}
|
||||
return loads
|
||||
}
|
||||
|
||||
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
|
||||
func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **The data items of a node's modules, placed, read from the composition** (novox/hq ADR 0571 clause 3, ADR 0233 §22 to
|
||||
// §25): disk-load measures each item every module on its node declares, and the node-backup holder, mounts and nfs-server
|
||||
// read the readings. Each item is its module's declaration with its path resolved to this node's place, its protection
|
||||
// and how it is measured: the same lines the node-backup holder is handed as `${backup:data}`, composed by the same
|
||||
// function, so a reader that composes from the bus and a holder handed the file read one list.
|
||||
|
||||
// PlacedDataItem is one data item a module on a node declares, as placed on that node.
|
||||
type PlacedDataItem struct {
|
||||
Module string `json:"module"`
|
||||
ID string `json:"item"`
|
||||
Class string `json:"class"`
|
||||
// Path is the item's place on the node; CoveredBy the path whose snapshot keeps it (the item, or the item its dump
|
||||
// writes into), empty when it is not backed up.
|
||||
Path string `json:"path"`
|
||||
CoveredBy string `json:"covered_by,omitempty"`
|
||||
// Protection is backup, redundancy, both or none; Measure walk, dataset or shallow.
|
||||
Protection string `json:"protection,omitempty"`
|
||||
Measure string `json:"measure,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
dataModuleHeader = regexp.MustCompile(`^#\s*([a-z0-9][a-z0-9-]*)$`)
|
||||
dataSafePath = regexp.MustCompile(`^/[^\s'"\\$` + "`" + `]*$`)
|
||||
)
|
||||
|
||||
// ParseDataItems reads the node-backup holder's data text (`${backup:data}`: a `# <module>` line, then one line per item,
|
||||
// `item <id> <class> <path> <covered-by or -> <protection> <measure>`) into each module's items, in order. A line it does
|
||||
// not read is refused, naming the module: a path with a space or a quote is never handed to a measuring shell.
|
||||
func ParseDataItems(text string) (map[string][]PlacedDataItem, error) {
|
||||
out := map[string][]PlacedDataItem{}
|
||||
module := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if m := dataModuleHeader.FindStringSubmatch(line); m != nil {
|
||||
module = m[1]
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") || module == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 5 || len(f) > 7 || f[0] != "item" || !dataSafePath.MatchString(f[3]) || (f[4] != "-" && !dataSafePath.MatchString(f[4])) {
|
||||
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
|
||||
}
|
||||
it := PlacedDataItem{Module: module, ID: f[1], Class: f[2], Path: f[3], Measure: MeasureWalk}
|
||||
if len(f) >= 6 {
|
||||
it.Protection = f[5]
|
||||
}
|
||||
if len(f) == 7 {
|
||||
it.Measure = f[6]
|
||||
}
|
||||
if it.Measure != MeasureWalk && it.Measure != MeasureDataset && it.Measure != MeasureShallow {
|
||||
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
|
||||
}
|
||||
if f[4] != "-" {
|
||||
it.CoveredBy = f[4]
|
||||
}
|
||||
out[module] = append(out[module], it)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text
|
||||
// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is
|
||||
// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out.
|
||||
func (r Resolution) DataItems(with Rendering) (items map[string][]PlacedDataItem, unplaced []string, err error) {
|
||||
left := r.LeftOut(with.Settings, with.Adopted)
|
||||
for module, why := range with.NotPlaced {
|
||||
if _, already := left[module]; !already {
|
||||
left[module] = why
|
||||
}
|
||||
}
|
||||
modules := make([]Manifest, 0, len(r.Modules))
|
||||
for _, m := range r.Modules {
|
||||
if _, isLeft := left[m.Module]; isLeft {
|
||||
m = backupView(m)
|
||||
}
|
||||
modules = append(modules, m)
|
||||
}
|
||||
text, unplaced, err := seatContributions(modules, Manifest{}, BackupSeat+":"+BackupKindData, with,
|
||||
machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
|
||||
if err != nil {
|
||||
return nil, unplaced, err
|
||||
}
|
||||
items, err = ParseDataItems(text)
|
||||
return items, unplaced, err
|
||||
}
|
||||
+10
-26
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -46,31 +45,6 @@ func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) {
|
||||
if it, _ := m.DataItem("dumps"); it.BackedUp() {
|
||||
t.Fatal("a rebuildable item that says none is backed up")
|
||||
}
|
||||
// Issue 774's new words first: an offer's credential form and an artifact's bus are accepted and change nothing: the
|
||||
// manifest that says them composes byte-equal to the one that does not, and no form still means a pair.
|
||||
plain := strings.Replace(declaredStore, `"resources":[`, `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/x","binary":"x","loads":["x"]}]},"resources":[`, 1)
|
||||
for _, form := range []string{"pair", "none"} {
|
||||
worded := strings.Replace(plain, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"`+form+`"}}]`, 1)
|
||||
worded = strings.Replace(worded, `"loads":["x"]}`, `"loads":["x"],"bus":{"state":["acts"],"removes":["engine-acts"]}}`, 1)
|
||||
a, b := mustParse(t, plain), mustParse(t, worded)
|
||||
if b.Provides[0].Credential != nil || b.CredentialFormOf("postgres-database") != a.CredentialFormOf("postgres-database") ||
|
||||
a.CredentialFormOf("postgres-database") != CredentialFormPair || len(b.Build.Artifacts[0].Bus) == 0 {
|
||||
t.Fatalf("form %s: offer %+v, form %q against %q", form, b.Provides[0], b.CredentialFormOf("postgres-database"),
|
||||
a.CredentialFormOf("postgres-database"))
|
||||
}
|
||||
b.Build.Artifacts[0].Bus = nil
|
||||
ra, erra := (Resolution{Node: "anchor", Modules: []Manifest{a}}).Compose(Rendering{})
|
||||
rb, errb := (Resolution{Node: "anchor", Modules: []Manifest{b}}).Compose(Rendering{})
|
||||
ja, _ := json.Marshal(ra.Resources)
|
||||
jb, _ := json.Marshal(rb.Resources)
|
||||
if erra != nil || errb != nil || string(ja) != string(jb) || len(ra.Resources) == 0 {
|
||||
t.Fatalf("form %s composes %s against %s (%v, %v)", form, jb, ja, errb, erra)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(strings.Replace(declaredStore, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"shared"}}]`, 1))); err == nil ||
|
||||
!strings.Contains(err.Error(), `the form "shared", not "pair" or "none"`) {
|
||||
t.Fatalf("a form the release does not know: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every rule of the section itself, refused at parse in the words of the module.
|
||||
@@ -218,6 +192,16 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
|
||||
if data != want {
|
||||
t.Fatalf("data lines:\n%s\nwant:\n%s", data, want)
|
||||
}
|
||||
// ADR 0571 clause 3: the same text, read into items, as disk-load and the backup holder read it; a path a shell
|
||||
// would split is refused.
|
||||
items, err := ParseDataItems(data)
|
||||
if err != nil || len(items) != 3 || items["media"][0] != (PlacedDataItem{Module: "media", ID: "films", Class: "irreplaceable",
|
||||
Path: "/tank/films", Protection: "redundancy", Measure: MeasureDataset}) || items["pg"][0].CoveredBy != "/srv/store/dumps" {
|
||||
t.Fatalf("the data lines read as %+v: %v", items, err)
|
||||
}
|
||||
if _, err := ParseDataItems("# a\nitem x cache /a b - none walk\n"); err == nil {
|
||||
t.Fatal("a path with a space was read")
|
||||
}
|
||||
if _, _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil {
|
||||
t.Fatal("a home directory with no account on the machine reached the holder as a placeholder")
|
||||
}
|
||||
|
||||
+16
-22
@@ -700,13 +700,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
handsSecrets := with.Handing(m)
|
||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
||||
if handsSecrets {
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
owner = o
|
||||
}
|
||||
owner := r.ownSecretOwner(m, name)
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name,
|
||||
}))
|
||||
@@ -718,13 +712,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if string(box) == string(ForeseenVaultBox) {
|
||||
*foreseen = append(*foreseen, m.Module+"/"+name)
|
||||
}
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
owner = o
|
||||
}
|
||||
owner := r.ownSecretOwner(m, name)
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box,
|
||||
}))
|
||||
@@ -754,14 +742,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// the manifest, because a manifest cannot say ${machine:account} safely — a node with no
|
||||
// account has nothing to resolve it to, and then the runtime runs as root and the file
|
||||
// stays root's.
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
owner = o
|
||||
}
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
first = append(first, ownedBy(r.ownSecretOwner(m, name), map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
|
||||
}))
|
||||
}
|
||||
@@ -1677,6 +1658,19 @@ func (r Resolution) provisionsAs(m Manifest) string {
|
||||
return m.SecretsOwner
|
||||
}
|
||||
|
||||
// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the
|
||||
// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR
|
||||
// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none).
|
||||
func (r Resolution) ownSecretOwner(m Manifest, name string) string {
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
return o
|
||||
}
|
||||
if r.Account != "" && (m.Module == RuntimeModule || ToolsBusSecret(m, name)) {
|
||||
return r.Account
|
||||
}
|
||||
return m.SecretsOwner
|
||||
}
|
||||
|
||||
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
||||
//
|
||||
// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import "fmt"
|
||||
|
||||
// The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery,
|
||||
// which owns a delivery — one commit in one repository, from its pull request's head to every machine —
|
||||
// and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has
|
||||
@@ -105,6 +107,29 @@ func SeatSays(emits []string, event string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// seatEventProblems refuses a module that lists among its own `emits` an event a seat it claims says (the lead,
|
||||
// 2026-10-29, issue 1952, the second of a kind: the self-check's `checked` and mesh-hand-acts' `act-recorded`). A
|
||||
// consumer of `<seat>.<event>` hears the seat's subject (SeatSays), so the holder says it there, with stdio.SeatPublish
|
||||
// on `mesh.seat.<seat>.event.<event>`; said through stdio.Emit as the module's own, it lands where nobody listens. Caught
|
||||
// here, when the manifest is read at the build, not when nothing is heard on the machines.
|
||||
func seatEventProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, c := range m.Claims {
|
||||
seat, known := SeatNamed(c.Name)
|
||||
if !known || len(seat.Emits) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, e := range m.Emits {
|
||||
if SeatSays(seat.Emits, e) {
|
||||
problems = append(problems, fmt.Sprintf("%s lists %q among its own emits, and the seat %s it claims says it: "+
|
||||
"say it on the seat with stdio.SeatPublish(\"mesh.seat.%s.event.%s\", ...) and leave it out of emits, or "+
|
||||
"no consumer of %s.%s hears it", m.Module, e, seat.Name, seat.Name, e, seat.Name, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and
|
||||
// `>` or `**` the rest.
|
||||
func topicMatches(pattern, event string) bool {
|
||||
|
||||
@@ -42,9 +42,10 @@ func consumerOn(t *testing.T, m Manifest, w World) Resolution {
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
stores := shelf(m,
|
||||
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
|
||||
Credential: &OfferCredential{Form: CredentialFormPair}}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormPair}}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}})
|
||||
r, err := Resolve(stores, []string{m.Module}, node, w)
|
||||
if err != nil {
|
||||
@@ -130,7 +131,10 @@ func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.
|
||||
return mustParse(t, raw+`}`)
|
||||
}
|
||||
for credential, want := range map[string]string{
|
||||
``: CredentialFormPair,
|
||||
// No default (the lead, 2026-10-29): an offer that says nothing has no form, and the catalogue refuses it.
|
||||
``: "",
|
||||
`,"credential":{"form":"pair"}`: CredentialFormPair,
|
||||
`,"credential":{"form":"none"}`: CredentialFormNone,
|
||||
`,"credential":{"own":"admin"}`: CredentialFormShared,
|
||||
`,"credential":{"own":"token"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"outside"}`: CredentialFormGiven,
|
||||
@@ -153,6 +157,18 @@ func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.
|
||||
if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
|
||||
t.Errorf("a module offering nothing has the credential form %q", got)
|
||||
}
|
||||
// The catalogue refuses an offer saying no form, and a form beside an own secret or not pair or none.
|
||||
for credential, refused := range map[string]string{``: "says no credential form", `,"credential":{}`: "says no credential form",
|
||||
`,"credential":{"form":"pair","own":"admin"}`: "beside an own secret", `,"credential":{"form":"maybe"}`: `not "pair" or "none"`,
|
||||
`,"credential":{"form":"none"}`: ""} {
|
||||
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + credential + `}]}`
|
||||
var m Manifest
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
problems := strings.Join(CredentialFormProblems(m), "; ")
|
||||
if (refused == "") != (problems == "") || !strings.Contains(problems, refused) {
|
||||
t.Errorf("%q: %q, want %q", credential, problems, refused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's
|
||||
|
||||
@@ -24,12 +24,15 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) {
|
||||
if string(out) != `["changes",{"name":"moves","max-mib":2048}]` {
|
||||
t.Fatalf("written back as %s", out)
|
||||
}
|
||||
// Issue 774's new words first: max-age-days is accepted and written back; nothing reads it yet.
|
||||
aged, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
|
||||
if out, _ := json.Marshal(aged.Logs); err != nil || aged.Logs[0].MaxAgeDays != 400 || string(out) != `[{"name":"acts","max-age-days":400}]` {
|
||||
t.Fatalf("max-age-days read as %+v, written %s: %v", aged.Logs, out, err)
|
||||
// 1953 S-age (ADR 0565 §10): a log may keep its entries for an age, written back with it.
|
||||
aged, err := ParseManifest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
|
||||
if err != nil || aged.Logs[0].MaxAgeDays != 400 {
|
||||
t.Fatalf("an aged log read as %+v, %v", aged.Logs, err)
|
||||
}
|
||||
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} {
|
||||
if out, _ := json.Marshal(aged.Logs); string(out) != `[{"name":"acts","max-age-days":400}]` {
|
||||
t.Fatalf("an aged log written back as %s", out)
|
||||
}
|
||||
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`, `{"name":"a","max-age-days":3650}`} {
|
||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil {
|
||||
t.Errorf("%s is inside the caps and was refused: %v", edge, err)
|
||||
}
|
||||
@@ -48,7 +51,7 @@ func TestALogIsNamedLocallyAndCapped(t *testing.T) {
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`},
|
||||
{`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.manifest))
|
||||
|
||||
+50
-25
@@ -287,18 +287,12 @@ type OfferCredential struct {
|
||||
Own string `json:"own,omitempty"`
|
||||
IssuedBy string `json:"issued-by,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
// Form is the release's word for an offer that names no own secret and no issuer (novox/hq ADR 0570; issue 774's
|
||||
// new words first): CredentialFormPair or CredentialFormNone. Accepted and read by nothing yet: an offer that says
|
||||
// only its form is parsed as one that says no credential, which is a pair, as before.
|
||||
// Form says, for an offer that names no own secret and no issuer, which it is: CredentialFormPair, a login of
|
||||
// each consumer's own, or CredentialFormNone, no credential at all (the lead, 2026-10-29, for novox/hq ADR 0570):
|
||||
// never left to a default.
|
||||
Form string `json:"form,omitempty"`
|
||||
}
|
||||
|
||||
// The forms an offer's credential may say by itself (OfferCredential.Form).
|
||||
const (
|
||||
CredentialFormWordPair = "pair"
|
||||
CredentialFormWordNone = "none"
|
||||
)
|
||||
|
||||
// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305).
|
||||
const IssuedBySoftware = "software"
|
||||
|
||||
@@ -372,6 +366,9 @@ const (
|
||||
// CredentialFormGiven is given by a person (novox/hq ADR 0337): the offer's credential, or the own secret it
|
||||
// shares, is issued outside the mesh; the mesh never makes it.
|
||||
CredentialFormGiven = "given"
|
||||
// CredentialFormNone is an offer whose consumers present no credential: its outcome is the provider's have, and
|
||||
// the vault mints nothing for it.
|
||||
CredentialFormNone = "none"
|
||||
)
|
||||
|
||||
// CredentialFormOf is the credential form this module's offer of a provision declares, read from the offer's
|
||||
@@ -385,7 +382,7 @@ func (m Manifest) CredentialFormOf(provision string) string {
|
||||
}
|
||||
switch c := o.Credential; {
|
||||
case c == nil:
|
||||
return CredentialFormPair
|
||||
return "" // undeclared: refused at the catalogue (CredentialFormProblems), never read as a pair
|
||||
case c.IssuedBy == IssuedOutside:
|
||||
return CredentialFormGiven
|
||||
case c.IssuedBy == IssuedBySoftware:
|
||||
@@ -395,11 +392,33 @@ func (m Manifest) CredentialFormOf(provision string) string {
|
||||
case c.Own != "":
|
||||
return CredentialFormShared
|
||||
}
|
||||
return CredentialFormPair
|
||||
return o.Credential.Form
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CredentialFormProblems refuses an offer that does not say its credential's form (the lead, 2026-10-29): an own
|
||||
// secret, an issuer, or `form` pair or none, and `form` only where neither of the others is said.
|
||||
func CredentialFormProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, o := range m.Provides {
|
||||
c := o.Credential
|
||||
switch {
|
||||
case c == nil || (c.Own == "" && c.IssuedBy == "" && c.Form == ""):
|
||||
problems = append(problems, fmt.Sprintf("%s provides %q and says no credential form: say `credential` with "+
|
||||
"`form` pair (a login of each consumer's own) or none (no credential), or the own secret or issuer it is",
|
||||
m.Module, o.Name))
|
||||
case c.Form != "" && (c.Own != "" || c.IssuedBy != ""):
|
||||
problems = append(problems, fmt.Sprintf("%s provides %q with `form` beside an own secret or an issuer; `form` "+
|
||||
"says only pair or none", m.Module, o.Name))
|
||||
case c.Form != "" && c.Form != CredentialFormPair && c.Form != CredentialFormNone:
|
||||
problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, o.Name,
|
||||
c.Form, CredentialFormPair, CredentialFormNone))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ProvisionsSharing is every provision of this module whose credential is the named own secret.
|
||||
func (m Manifest) ProvisionsSharing(own string) []string {
|
||||
var out []string
|
||||
@@ -1021,6 +1040,8 @@ type Bundle struct {
|
||||
// Binary is the executable a bundle compiled to a binary is, at its root (novox/hq ADR 0193):
|
||||
// what runs it, where an interpreted bundle names an interpreter and an entrypoint.
|
||||
Binary string `json:"binary,omitempty"`
|
||||
// Bus is the artifact's bus account, kept on the resolved manifest so the grant composition reads it (BusAccounts).
|
||||
Bus *ProcessBus `json:"bus,omitempty"`
|
||||
}
|
||||
|
||||
// Build says how to produce this module's artifacts from its source.
|
||||
@@ -1158,10 +1179,10 @@ type Artifact struct {
|
||||
// delivered version. A bundle that carried the package's name was delivered correctly, reported
|
||||
// success, and was invisible to the launcher (novox/hq 04-ISSUES/142).
|
||||
Binary string `json:"binary,omitempty"`
|
||||
|
||||
// Bus is the release's word for a tools bundle's own bus account (novox/hq ADR 0564 Limits 4; issue 774's new words
|
||||
// first): accepted as it is written and read by nothing yet.
|
||||
Bus json.RawMessage `json:"bus,omitempty"`
|
||||
// Bus is a tools bundle's own bus account (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): read as a process's
|
||||
// (ProcessBus), a principal named after the bundle's binary (BusAccounts), its secret broker-<binary>. A tools bundle
|
||||
// is run by the node's tool runtime per call, so it declares its account here and runs no idle service for it.
|
||||
Bus *ProcessBus `json:"bus,omitempty"`
|
||||
|
||||
// URL is where an archive's upstream file is fetched from, for an archive that is a release
|
||||
// somebody else published rather than a directory of this repository (novox/hq ADR 0316 §2, §6).
|
||||
@@ -1783,6 +1804,10 @@ func (m Manifest) marshalFields() ([]byte, error) {
|
||||
return json.Marshal(keys)
|
||||
}
|
||||
|
||||
// CredentialProvisionPrefix starts the name of a provision's credential, the vault's provision beside it (novox/hq ADR
|
||||
// 0570 clause 6): `secret-<provision>`.
|
||||
const CredentialProvisionPrefix = "secret-"
|
||||
|
||||
// SecretFile is one file a module is given a credential in: the local name it goes by inside
|
||||
// the module (empty for the ordinary one-file case, where the requirement's name serves) and where.
|
||||
type SecretFile struct {
|
||||
@@ -1940,11 +1965,17 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
"%s says its instances are %q; the one word is %q, for a module that is the same on every machine",
|
||||
m.Module, m.Instances, InstancesInterchangeable))
|
||||
}
|
||||
for i, offer := range m.Provides {
|
||||
for _, offer := range m.Provides {
|
||||
p := offer.Name
|
||||
if !name.MatchString(p) {
|
||||
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
||||
}
|
||||
// A provision's credential is a provision of its own, `secret-<provision>` (novox/hq ADR 0570 clause 6, the
|
||||
// lead 2026-10-29): no module provides a name of that form, so the two keys never meet.
|
||||
if strings.HasPrefix(p, CredentialProvisionPrefix) {
|
||||
problems = append(problems, fmt.Sprintf("%s provides %q; a name starting %q is the mesh's for a provision's "+
|
||||
"credential, kept by the vault (novox/hq ADR 0570)", m.Module, p, CredentialProvisionPrefix))
|
||||
}
|
||||
// A bound says what keeps the name, so the refusal it causes can say it (ADR 0225); and it
|
||||
// leaves room for the shortest identity the mesh makes, or it would refuse every consumer.
|
||||
if id := offer.Identity; id != nil && !id.None {
|
||||
@@ -1993,15 +2024,8 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
} else if offer.Credential != nil {
|
||||
own, declared := m.OwnSecrets[offer.Credential.Own]
|
||||
switch {
|
||||
case offer.Credential.Own == "" && offer.Credential.IssuedBy == "" && offer.Credential.Tool == "" &&
|
||||
offer.Credential.Form != "":
|
||||
// The form alone (issue 774's new words first): pair or none, accepted and meaning what no credential
|
||||
// meant before, a pair (the offer is read as saying none).
|
||||
if f := offer.Credential.Form; f != CredentialFormWordPair && f != CredentialFormWordNone {
|
||||
problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, p, f,
|
||||
CredentialFormWordPair, CredentialFormWordNone))
|
||||
}
|
||||
m.Provides[i].Credential = nil
|
||||
case offer.Credential.Own == "" && offer.Credential.Form != "":
|
||||
// The form alone, pair or none (CredentialFormProblems judges it).
|
||||
case offer.Credential.Own == "":
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q with a credential that names no own secret", m.Module, p))
|
||||
@@ -2110,6 +2134,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
problems = append(problems, declaredSeatProblems(m)...)
|
||||
problems = append(problems, RunsAsProblems(m)...)
|
||||
problems = append(problems, ProcessBusProblems(m)...)
|
||||
problems = append(problems, seatEventProblems(m)...)
|
||||
problems = append(problems, ReadsHolderProblems(m)...)
|
||||
problems = append(problems, FilesHolderProblems(m)...)
|
||||
if m.Computed != "" && len(m.Resources) > 0 {
|
||||
|
||||
+184
-1
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,188 @@ type ProcessBus struct {
|
||||
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
|
||||
// seat:<seat>.<verb> (as Manifest.Invokes), or * for every tool on the mesh.
|
||||
Invokes []string `json:"invokes,omitempty"`
|
||||
// Removes are what a tools bundle's account may remove from the bus, from a closed set (BusRemovals; novox/hq ADR
|
||||
// 0564 Limits 4): declared alone, by the module holding mesh-broker, never by a process.
|
||||
Removes []string `json:"removes,omitempty"`
|
||||
}
|
||||
|
||||
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: the controller's consumer on
|
||||
// DEAD_LETTER_NOTICES (the lease write's step, clause 5), and keys of the node-engine's acts bucket. A further consumer
|
||||
// or bucket is a change of this set, through a decision.
|
||||
const (
|
||||
RemovesControllerDeadLetterConsumer = "DEAD_LETTER_NOTICES.controller"
|
||||
RemovesEngineActs = "mesh-host_acts"
|
||||
)
|
||||
|
||||
// BusRemovals is the closed set of what a bus account may remove.
|
||||
var BusRemovals = []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs}
|
||||
|
||||
// BusAccount is one account of a module's own beside its `broker`: a process's (ADR 0436) or a tools bundle's (ADR 0564
|
||||
// Limits 4), by its id (the process resource's, or the bundle's binary), with what its bus says.
|
||||
type BusAccount struct {
|
||||
ID string
|
||||
Bus ProcessBus
|
||||
// Tools says it is a tools bundle's, run by the node's tool runtime, not a process the node runs.
|
||||
Tools bool
|
||||
}
|
||||
|
||||
// BusAccounts is every own bus account of the module, processes first in resource order, then tools bundles in artifact
|
||||
// order: what the grant composition composes a principal for, each delivered in its own secret broker-<id>. A tools
|
||||
// bundle's is read from the repository's build, or from the resolved manifest's bundles.
|
||||
func (m Manifest) BusAccounts() []BusAccount {
|
||||
var out []BusAccount
|
||||
buses := m.ProcessBuses()
|
||||
for _, r := range m.Resources {
|
||||
id := fmt.Sprint(r["id"])
|
||||
if b, ok := buses[id]; ok && fmt.Sprint(r["type"]) == "process" {
|
||||
out = append(out, BusAccount{ID: id, Bus: b})
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
if m.Build != nil {
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Bus != nil && a.Kind == ArtifactBundle && BinaryOf(a) != "" && !seen[BinaryOf(a)] {
|
||||
seen[BinaryOf(a)] = true
|
||||
out = append(out, BusAccount{ID: BinaryOf(a), Bus: *a.Bus, Tools: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range m.Bundles {
|
||||
if b.Bus != nil && b.Binary != "" && !seen[b.Binary] {
|
||||
seen[b.Binary] = true
|
||||
out = append(out, BusAccount{ID: b.Binary, Bus: *b.Bus, Tools: true})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToolsBusSecret says name is the own secret a tools bundle's bus account is held in: `broker-<binary>` for a bundle
|
||||
// whose bus the manifest declares (ADR 0564 Limits 4). Its file belongs to the runtime's account, which runs the bundle.
|
||||
func ToolsBusSecret(m Manifest, name string) bool {
|
||||
for _, a := range m.BusAccounts() {
|
||||
if a.Tools && name == "broker-"+a.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolsSecretProblems refuses a tools bundle's bus account secret the runtime's account could not read: it is declared
|
||||
// as an own secret `broker-<binary>`, at `${dir:<id>}/broker-<binary>` for a directory resource every parent of which the
|
||||
// runtime's account may pass (passableDir); the file itself is 0600 and the runtime account's.
|
||||
func toolsSecretProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, a := range m.BusAccounts() {
|
||||
if !a.Tools {
|
||||
continue
|
||||
}
|
||||
name := "broker-" + a.ID
|
||||
own, declared := m.OwnSecrets.Plain()[name]
|
||||
if !declared {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools bundle %s declares bus and no own secret %s to hold its "+
|
||||
"account in (novox/hq ADR 0564 Limits 4)", m.Module, a.ID, name))
|
||||
continue
|
||||
}
|
||||
dir, _, _ := strings.Cut(strings.TrimPrefix(own.Path, "${dir:"), "}")
|
||||
if !strings.HasPrefix(own.Path, "${dir:"+dir+"}/") || !passableDir(m, dir, 0) {
|
||||
problems = append(problems, fmt.Sprintf("%s's own secret %s is at %s, which the node's tool runtime cannot "+
|
||||
"reach: a tools bundle's account lies in a directory resource the runtime's account may pass, every parent "+
|
||||
"included, such as {\"id\":\"tools-secret\",\"type\":\"directory\",\"path\":\"%s/%s-tools\",\"mode\":\"0711\"} "+
|
||||
"with the secret at ${dir:tools-secret}/%s; never under the module's own or mesh place, which are its "+
|
||||
"own (novox/hq ADR 0564 Limits 4)", m.Module, name, own.Path, meshStateRoot, m.Module, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// meshStateRoot is the mesh's directory of every module's mesh place on a node of the default data root: root's, mode
|
||||
// 0755, so the runtime's account passes it, while each module's own place beneath it is 0700.
|
||||
const meshStateRoot = defaultDataRoot + "/" + placeMesh
|
||||
|
||||
// passableDir says the runtime's account may pass the directory resource id and every parent the mesh makes for it: its
|
||||
// own mode lets others pass (the last digit odd), and it is either stated directly under meshStateRoot or beneath a
|
||||
// placed directory that is passable in turn. A pathless directory resolves under the module's own root or its mesh
|
||||
// place, which are the module's own, so it never is.
|
||||
func passableDir(m Manifest, id string, depth int) bool {
|
||||
if depth > 4 {
|
||||
return false
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "directory" || fmt.Sprint(r["id"]) != id {
|
||||
continue
|
||||
}
|
||||
mode, _ := r["mode"].(string)
|
||||
if mode == "" || !strings.ContainsAny(mode[len(mode)-1:], "1357") {
|
||||
return false
|
||||
}
|
||||
path, _ := r["path"].(string)
|
||||
switch {
|
||||
case strings.HasPrefix(path, "${dir:"):
|
||||
parent, _, _ := strings.Cut(strings.TrimPrefix(path, "${dir:"), "}")
|
||||
return passableDir(m, parent, depth+1)
|
||||
case strings.HasPrefix(path, "/"):
|
||||
return filepathDir(strings.TrimRight(path, "/")) == meshStateRoot
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filepathDir(p string) string {
|
||||
if i := strings.LastIndex(p, "/"); i > 0 {
|
||||
return p[:i]
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// toolsBusProblems is what is wrong with a tools bundle's bus (ADR 0564 Limits 4): a bus on anything but a bundle
|
||||
// compiled to a binary the runtime loads, a removal outside the closed set, or a removal beside any other grant or by a
|
||||
// module that does not hold mesh-broker. Every other field is judged as a process's.
|
||||
func toolsBusProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
var artifacts []Artifact
|
||||
if m.Build != nil {
|
||||
artifacts = m.Build.Artifacts
|
||||
}
|
||||
claimsBroker := false
|
||||
for _, c := range m.Claims {
|
||||
claimsBroker = claimsBroker || c.Name == "mesh-broker"
|
||||
}
|
||||
for _, a := range artifacts {
|
||||
if a.Bus == nil {
|
||||
continue
|
||||
}
|
||||
if a.Kind != ArtifactBundle || BinaryOf(a) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's artifact %s declares bus, and only a tools bundle compiled to a "+
|
||||
"binary has an account of its own (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
|
||||
continue
|
||||
}
|
||||
b := *a.Bus
|
||||
if len(b.Queues) > 0 || b.Asks {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus consumes a queue or reads asks; a tools "+
|
||||
"bundle runs per call and holds no consumer (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
|
||||
}
|
||||
for _, r := range b.Removes {
|
||||
if !slices.Contains(BusRemovals, r) {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes %q, which is not one of %s "+
|
||||
"(novox/hq ADR 0564 Limits 4)", m.Module, a.Name, r, strings.Join(BusRemovals, ", ")))
|
||||
}
|
||||
}
|
||||
if len(b.Removes) > 0 && (!claimsBroker || len(b.Emits)+len(b.State)+len(b.Invokes) > 0) {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes from the bus; only the bus "+
|
||||
"module's (the holder of mesh-broker) may, and with nothing else (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "process" {
|
||||
continue
|
||||
}
|
||||
if b, present, err := processBusOf(r); present && err == nil && len(b.Removes) > 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s's process %v's bus removes from the bus; only a tools bundle's "+
|
||||
"may (novox/hq ADR 0564 Limits 4)", m.Module, r["id"]))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a process's bus strictly: an unknown key — serves, a seat to hold or claim, or
|
||||
@@ -87,7 +270,7 @@ func (m Manifest) ProcessBuses() map[string]ProcessBus {
|
||||
// §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or
|
||||
// seat verb's shape on invokeProblems.
|
||||
func ProcessBusProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
problems := append(toolsBusProblems(m), toolsSecretProblems(m)...)
|
||||
streams := map[string]bool{}
|
||||
for _, s := range m.Streams {
|
||||
streams[s.Name] = true
|
||||
|
||||
@@ -47,6 +47,65 @@ func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) {
|
||||
!b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) {
|
||||
t.Fatalf("the bus read as %+v", b)
|
||||
}
|
||||
|
||||
// 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a
|
||||
// process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the
|
||||
// bus module's (mesh-broker) alone.
|
||||
toolsIn := func(claims, bus, mode, where string) string {
|
||||
return `{"module":"nats","version":"1","claims":` + claims + `,"own-secrets":{"broker":"${dir:mesh-state}/broker",` +
|
||||
`"broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},"resources":[{"id":"mesh-state","type":"directory",` +
|
||||
`"mode":"0700","place":"mesh","unknown":"program"},{"id":"tools-secret","type":"directory","mode":"` + mode +
|
||||
`","path":"` + where + `","unknown":"program"}],"build":{"artifacts":[{"name":"tools","kind":"bundle",` +
|
||||
`"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}`
|
||||
}
|
||||
tools := func(claims, bus string) string { return toolsIn(claims, bus, "0711", "/var/lib/mesh/nats-tools") }
|
||||
broker := `[{"name":"mesh-broker","scope":"mesh"}]`
|
||||
for _, c := range []struct {
|
||||
name, manifest, refused string
|
||||
}{
|
||||
{name: "the bus module's tools removing", manifest: tools(broker, `{"removes":["DEAD_LETTER_NOTICES.controller","mesh-host_acts"]}`)},
|
||||
{name: "a removal outside the set", manifest: tools(broker, `{"removes":["EVENTS.a_m"]}`), refused: "not one of"},
|
||||
{name: "a removal beside a grant", manifest: tools(broker, `{"removes":["mesh-host_acts"],"invokes":["nats.nats_streams"]}`), refused: "with nothing else"},
|
||||
{name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"},
|
||||
{name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"},
|
||||
{name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"},
|
||||
// The runtime's account reads the account's secret: it lies where others may pass (the host agent, 2026-10-29).
|
||||
{name: "a tools secret the runtime cannot reach", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0700",
|
||||
"/var/lib/mesh/nats-tools"), refused: "cannot reach"},
|
||||
// Its parents too: under the module's mesh place, /var/lib/mesh/nats is 0700 (the lead, read on novox 2026-10-29).
|
||||
{name: "a tools secret beneath the module's mesh place", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0711",
|
||||
"${dir:mesh-state}/tools"), refused: "cannot reach"},
|
||||
} {
|
||||
m, err := ParseManifest([]byte(c.manifest))
|
||||
if c.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
||||
t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
accounts := m.BusAccounts()
|
||||
if len(accounts) != 1 || accounts[0].ID != "nats-tools" || !accounts[0].Tools ||
|
||||
!strSliceEqual(accounts[0].Bus.Removes, []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs}) {
|
||||
t.Errorf("%s: the accounts are %+v", c.name, accounts)
|
||||
}
|
||||
// Its secret's file is the runtime account's, as the runtime's own credential is; the module's other secrets stay
|
||||
// its own.
|
||||
if (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools") != "ops" ||
|
||||
(Resolution{Account: "ops"}).ownSecretOwner(m, "broker") != "" || (Resolution{}).ownSecretOwner(m, "broker-nats-tools") != "" {
|
||||
t.Errorf("%s: the tools secret's owner is %q", c.name, (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools"))
|
||||
}
|
||||
// Resolved, the build goes and its bundles keep the bus (build.go).
|
||||
resolved := Manifest{Module: "nats", Bundles: []Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}}
|
||||
if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" {
|
||||
t.Errorf("%s: resolved, the accounts are %+v", c.name, got)
|
||||
}
|
||||
}
|
||||
if got := m.BusAccounts(); len(got) != 1 || got[0].ID != "send" || got[0].Tools {
|
||||
t.Errorf("the process's account is %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func strSliceEqual(a, b []string) bool {
|
||||
|
||||
@@ -282,6 +282,12 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
problems = append(problems, RestrictedHearingProblems(shelf)...)
|
||||
// A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6).
|
||||
problems = append(problems, WorkQueueConsumedElsewhereProblems(shelf)...)
|
||||
// Every offer says its credential's form, never left to a default (the lead, 2026-10-29, for novox/hq ADR 0570).
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
problems = append(problems, CredentialFormProblems(shelf[module])...)
|
||||
// And its data in the bus's predefined forms, a stream of its own refused (the lead, 2026-10-29).
|
||||
problems = append(problems, PredefinedFormProblems(shelf[module])...)
|
||||
}
|
||||
|
||||
// Who declares what, and who declared it first.
|
||||
declaredBy := map[string]string{}
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestAClaimMustMatchTheDeclaredScope(t *testing.T) {
|
||||
func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) {
|
||||
// It delivers the bus, so its holder provides the bus — the rule this check now enforces.
|
||||
m := Manifest{Module: "nats",
|
||||
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormNone}}},
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
if got := problemsFor(t, Shelf{"nats": m}); got != "" {
|
||||
t.Fatalf("a mesh seat was refused by the derived check: %s", got)
|
||||
@@ -186,7 +186,7 @@ func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) {
|
||||
// A store whose bus seat delivers something this module does provide.
|
||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
|
||||
raw := []byte(`{"module":"a-bus","version":"1",` +
|
||||
`"provides":[{"name":"mesh-bus","scope":"mesh"}],` +
|
||||
`"provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],` +
|
||||
`"claims":[{"name":"mesh-broker","scope":"mesh"}]}`)
|
||||
|
||||
m, err := ParseManifest(raw)
|
||||
|
||||
@@ -188,6 +188,18 @@ func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) {
|
||||
if problems := CatalogueProblems(Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 {
|
||||
t.Fatalf("a claim on a declared seat was refused: %v", problems)
|
||||
}
|
||||
// The second of a kind (issue 1952; the lead, 2026-10-29): a holder that lists its seat's event among its own emits
|
||||
// would say it where no consumer hears it, so the manifest is refused, naming the seat's subject; the seat's
|
||||
// holder saying nothing of its own is accepted.
|
||||
holder := func(emits string) []byte {
|
||||
return []byte(`{"module":"mesh-self-check","version":"1","emits":` + emits + `,"claims":[{"name":"mesh-self-check","scope":"mesh","serves":["doctor","probes"]}]}`)
|
||||
}
|
||||
if _, err := ParseManifest(holder(`["checked"]`)); err == nil || !strings.Contains(err.Error(), "mesh.seat.mesh-self-check.event.checked") {
|
||||
t.Errorf("a seat's event listed as the module's own: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest(holder(`[]`)); err != nil {
|
||||
t.Errorf("a holder saying its seat's event on the seat was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing
|
||||
|
||||
@@ -630,3 +630,31 @@ func uncovered(patterns []string) []string {
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// **A module keeps its data in the bus's predefined forms** (the lead and the operator, 2026-10-29; the operator's rule of
|
||||
// 2026-10-22, "data storage is done using our predefined nats bus options"): a state, a log or an object store, which the
|
||||
// runtime reads and writes for its tools and other modules read by a declared `reads`. A stream of its own is outside
|
||||
// them and refused, naming it. StreamsKeptUntilMoved are the streams declared before this check, each allowed by name
|
||||
// until its module moves it; the list only shrinks.
|
||||
var StreamsKeptUntilMoved = map[string][]string{
|
||||
"brother": {"device-asks"},
|
||||
"mesh-build-go": {"toolchains-go"},
|
||||
"mesh-build-node": {"toolchains-node"},
|
||||
"mesh-mail": {"mail", "mail-sends"},
|
||||
"only-office": {"convert-asks"},
|
||||
"mesh-delivery-assigner": {"placement"},
|
||||
"mesh-delivery-checker": {"checks"},
|
||||
"mesh-delivery-forge-bridge": {"forge"},
|
||||
}
|
||||
|
||||
// PredefinedFormProblems refuses each stream a module declares that StreamsKeptUntilMoved does not name for it.
|
||||
func PredefinedFormProblems(m Manifest) []string {
|
||||
var out []string
|
||||
for _, s := range m.Streams {
|
||||
if !slices.Contains(StreamsKeptUntilMoved[m.Module], s.Name) {
|
||||
out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+
|
||||
"a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -96,6 +96,14 @@ func TestTwoModulesCannotDeclareOneStream(t *testing.T) {
|
||||
if got := StreamNameProblems(Shelf{"a": a}); len(got) != 0 {
|
||||
t.Fatalf("one owner refused: %v", got)
|
||||
}
|
||||
// And a stream of its own is outside the bus's predefined forms (the lead, 2026-10-29): refused, naming it, unless it
|
||||
// is one kept by name until its module moves it.
|
||||
if !strings.Contains(got, `a keeps the stream "mail", outside the bus's predefined forms`) {
|
||||
t.Fatalf("a stream of its own was not refused: %s", got)
|
||||
}
|
||||
if got := PredefinedFormProblems(Manifest{Module: "mesh-mail", Streams: a.Streams}); len(got) != 0 {
|
||||
t.Fatalf("a stream kept until it moves was refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The setting's value names who hears each key: a module wherever it runs, or on one machine (ADR 0363 §4).
|
||||
|
||||
@@ -3,6 +3,7 @@ package catalogue
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the mesh does when a module's build moves (novox/hq ADR 0236, extending ADR 0162 §3 and ADR
|
||||
@@ -118,3 +119,46 @@ func (m Manifest) irreplaceable() string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PersonPolicy is a person's choice of a module's upgrade policy, as the assigner keeps it in its state `policies`, one
|
||||
// key per module (novox/hq ADR 0565 clause 7, issue 1953 L5-S2): the policy, why, who as the runtime proved the caller,
|
||||
// and when. Set and taken back through `mesh-wants.upgrade`, each recorded as a hand act.
|
||||
type PersonPolicy struct {
|
||||
V int `json:"v"`
|
||||
Module string `json:"module"`
|
||||
Policy string `json:"policy"`
|
||||
Why string `json:"why"`
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// PersonPolicyVersion is the version of a person's policy's layout.
|
||||
const PersonPolicyVersion = 1
|
||||
|
||||
// Check refuses a choice the assigner could not keep: a policy that is not roll, together or record, one other than
|
||||
// roll without why, or one with no person.
|
||||
func (p PersonPolicy) Check() error {
|
||||
switch {
|
||||
case p.Module == "":
|
||||
return fmt.Errorf("a person's policy names its module")
|
||||
case p.Policy != PolicyRoll && p.Policy != PolicyTogether && p.Policy != PolicyRecord:
|
||||
return fmt.Errorf("%s: a policy is %q, %q or %q, not %q", p.Module, PolicyRoll, PolicyTogether, PolicyRecord, p.Policy)
|
||||
case strings.TrimSpace(p.Why) == "":
|
||||
return fmt.Errorf("%s: a person's policy says why", p.Module)
|
||||
case strings.TrimSpace(p.By) == "":
|
||||
return fmt.Errorf("%s: a person's policy names who chose it", p.Module)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upgrade is a module's policy and where it comes from (ADR 0565 clause 7): the one derivation every reader takes,
|
||||
// `mesh-wants.upgrade` and each node agent's judgement of a move alike. A person's choice over the module's word, its
|
||||
// irreplaceable data and the default; the bus is never rolled, whoever says so (ADR 0236). By names the person when one
|
||||
// chose.
|
||||
func Upgrade(m Manifest, chosen *PersonPolicy) (policy, from, why, by string) {
|
||||
policy, from, why = DerivedUpgrade(m)
|
||||
if from == FromBus || chosen == nil {
|
||||
return policy, from, why, ""
|
||||
}
|
||||
return chosen.Policy, FromPerson, chosen.Why, chosen.By
|
||||
}
|
||||
|
||||
@@ -32,11 +32,28 @@ func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) {
|
||||
if policy != c.policy || from != c.from {
|
||||
t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from)
|
||||
}
|
||||
// ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source but the bus; none leaves the
|
||||
// derivation as it was.
|
||||
person := &PersonPolicy{V: PersonPolicyVersion, Module: c.m.Module, Policy: PolicyTogether, Why: "one zone", By: "person.jochen"}
|
||||
policy, from, _, by := Upgrade(c.m, person)
|
||||
wantPolicy, wantFrom, wantBy := PolicyTogether, FromPerson, "person.jochen"
|
||||
if c.from == FromBus {
|
||||
wantPolicy, wantFrom, wantBy = c.policy, FromBus, ""
|
||||
}
|
||||
if policy != wantPolicy || from != wantFrom || by != wantBy {
|
||||
t.Errorf("%s with a person's choice: %s from %s by %q", c.name, policy, from, by)
|
||||
}
|
||||
if policy, from, _, _ := Upgrade(c.m, nil); policy != c.policy || from != c.from {
|
||||
t.Errorf("%s with no choice: %s from %s", c.name, policy, from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A policy other than roll says why, and an unknown one is refused.
|
||||
func TestAnUpgradePolicySaysWhy(t *testing.T) {
|
||||
for _, p := range []PersonPolicy{{Module: "m", Policy: "sometimes", Why: "x", By: "p"}, {Module: "m", Policy: PolicyRecord, By: "p"},
|
||||
{Module: "m", Policy: PolicyRecord, Why: "x"}} {
|
||||
if p.Check() == nil {
|
||||
t.Errorf("%+v was accepted", p)
|
||||
}
|
||||
}
|
||||
// A policy other than roll says why, and an unknown one is refused.
|
||||
for _, u := range []UpgradePolicy{{Policy: PolicyRecord}, {Policy: PolicyTogether}, {Policy: "sometimes", Why: "x"}} {
|
||||
if problems := (Manifest{Module: "m", Upgrade: &u}).upgradeProblems(); len(problems) == 0 {
|
||||
t.Errorf("%+v was accepted", u)
|
||||
|
||||
@@ -130,6 +130,22 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
n.Kept = kept
|
||||
}
|
||||
}
|
||||
// A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
|
||||
// (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
|
||||
for i := range reading.Nodes {
|
||||
n := &reading.Nodes[i]
|
||||
if n.Name != node || n.RunsTools == nil {
|
||||
continue
|
||||
}
|
||||
kept := make(map[string]catalogue.Manifest, len(n.Kept))
|
||||
for m, man := range n.Kept {
|
||||
if man.Stubbed && man.RunsAs == "" {
|
||||
man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
|
||||
}
|
||||
kept[m] = man
|
||||
}
|
||||
n.Kept = kept
|
||||
}
|
||||
isCarried := func(module, id string) bool {
|
||||
if module == "" {
|
||||
module, _ = declaration.ModuleGuess(id)
|
||||
@@ -293,6 +309,106 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
|
||||
// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
|
||||
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
|
||||
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
|
||||
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
|
||||
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID()
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
var env map[string]string
|
||||
out := map[string][]catalogue.Bundle{}
|
||||
for _, c := range running {
|
||||
for _, raw := range c.Resources {
|
||||
var r resource
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
continue
|
||||
}
|
||||
owner := r.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
switch {
|
||||
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
|
||||
env = r.Env
|
||||
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")):
|
||||
name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID(""))
|
||||
if r.Path == catalogue.BundlePath(owner, name) {
|
||||
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return nil
|
||||
}
|
||||
var words map[string]map[string]string
|
||||
_ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
|
||||
for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
|
||||
module, path, ok := strings.Cut(entry, "=")
|
||||
if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
|
||||
continue
|
||||
}
|
||||
name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
|
||||
bundles := out[module]
|
||||
i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
|
||||
if !ok || i < 0 {
|
||||
continue // no archive of it runs: nothing to load
|
||||
}
|
||||
var own map[string]string
|
||||
for w, v := range words[module] {
|
||||
if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
|
||||
continue // the mesh's to say, composed again
|
||||
}
|
||||
if own == nil {
|
||||
own = map[string]string{}
|
||||
}
|
||||
own[w] = v
|
||||
}
|
||||
bundles[i].Loads = append(bundles[i].Loads, load)
|
||||
bundles[i].Env = own
|
||||
}
|
||||
for _, bundles := range out {
|
||||
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
|
||||
// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
|
||||
// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
|
||||
func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
|
||||
var out []catalogue.Bundle
|
||||
for _, b := range runs {
|
||||
if len(b.Loads) == 0 {
|
||||
for _, built := range man.Bundles {
|
||||
if built.Name == b.Name {
|
||||
b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
|
||||
}
|
||||
}
|
||||
if man.Build != nil {
|
||||
run := catalogue.RunByAResource(man)
|
||||
for _, a := range man.Build.Artifacts {
|
||||
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
|
||||
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
|
||||
// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
|
||||
// alone (issue 1126).
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
@@ -96,6 +97,91 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
|
||||
t.Errorf("a node the mesh does not have was composed")
|
||||
}
|
||||
|
||||
// Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
|
||||
// contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
|
||||
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
|
||||
// held module's new build's bundles are not delivered.
|
||||
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
|
||||
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
|
||||
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
|
||||
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
|
||||
store := catalogue.Manifest{Module: "artifacts", Version: "1",
|
||||
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
|
||||
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
|
||||
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
|
||||
newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools"
|
||||
runtimeRuns := func(dropped bool) delivery.Composed {
|
||||
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
|
||||
if dropped {
|
||||
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
|
||||
}
|
||||
return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
|
||||
}
|
||||
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
|
||||
`","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
set func(n *Node) delivery.Composed // and what runs of lamp
|
||||
fps map[string]string
|
||||
dropped bool
|
||||
}{
|
||||
{"folded at a build of the old form", func(n *Node) delivery.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, false},
|
||||
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, true},
|
||||
{"held, its new build not delivered", func(n *Node) delivery.Composed {
|
||||
n.Held = map[string]bool{"lamp": true}
|
||||
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
|
||||
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
|
||||
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
|
||||
return lr
|
||||
}, map[string]string{"lamp": fpFor('1')}, false},
|
||||
} {
|
||||
tools := aMesh(t)
|
||||
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
|
||||
anchor := nodeOf(t, &tools, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
|
||||
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
|
||||
anchor.SealingKey = keyOf(t, "anchor")
|
||||
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
|
||||
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
|
||||
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
var env map[string]any
|
||||
for _, c := range out {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Env map[string]any `json:"env"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
|
||||
env = r.Env
|
||||
}
|
||||
}
|
||||
}
|
||||
if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
|
||||
t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
|
||||
env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
|
||||
|
||||
@@ -23,6 +23,10 @@ const (
|
||||
// Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would
|
||||
// refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words.
|
||||
Foreseeing
|
||||
// Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none
|
||||
// for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one.
|
||||
// The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied.
|
||||
Planning
|
||||
)
|
||||
|
||||
// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send.
|
||||
@@ -344,6 +348,10 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c
|
||||
continue
|
||||
}
|
||||
sealed, held := c.moduleSecretIfIssued(node, m.Module, name)
|
||||
if !held && mode == Planning {
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if !held && mode == Foreseeing {
|
||||
// Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
|
||||
// named; refused by it -- refused here, the same words (novox/hq issue 275).
|
||||
|
||||
@@ -308,6 +308,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
}
|
||||
}
|
||||
if node == s.Node {
|
||||
// What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126).
|
||||
n.RunsTools = compose.ToolsRunning(s.Running)
|
||||
for _, module := range s.InOwn {
|
||||
if n.InOwn == nil {
|
||||
n.InOwn = map[string]bool{}
|
||||
@@ -424,6 +426,48 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
|
||||
return out, skipped, held, err
|
||||
}
|
||||
|
||||
// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus
|
||||
// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control
|
||||
// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names
|
||||
// templates, files, own secrets by name, items, collections and provisions, never a value, so no value is read for it:
|
||||
// the seat mesh-provisioner's holder composes every node's plans with a Reader that reads no secret.
|
||||
func Plans(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string]catalogue.Plan, error) {
|
||||
in, err := Input(ctx, r, mesh, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return PlansOf(in, s.Node)
|
||||
}
|
||||
|
||||
// PlansOf is Plans over an input already filled, rendered in the Planning mode (compose.Planning): every own secret the
|
||||
// input holds none for stands in, so no value and no record of one is needed.
|
||||
func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
|
||||
return plansIn(in, node, compose.Planning)
|
||||
}
|
||||
|
||||
// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own.
|
||||
func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) {
|
||||
resolved, err := compose.Resolve(in, node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
rendered, err := compose.Render(in, node, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
gens, err := compose.Generators(in)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
with := rendered.Rendering
|
||||
with.Generators = gens
|
||||
composed, err := resolved.Plan.Compose(with)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
return composed.Plans, nil
|
||||
}
|
||||
|
||||
// parser reads a manifest as the indexer does, against the mesh's predefined keys when given.
|
||||
func parser(mesh Mesh) func([]byte) (catalogue.Manifest, error) {
|
||||
return func(raw []byte) (catalogue.Manifest, error) {
|
||||
@@ -809,3 +853,13 @@ func membershipOf(own delivery.Composed) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DataItems is every data item the modules placed on scope's node declare, placed there, composed from the bus as Plans
|
||||
// composes (novox/hq ADR 0571 clause 3): disk-load measures these.
|
||||
func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) {
|
||||
in, err := Input(ctx, r, mesh, s)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return compose.DataItems(in, s.Node)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -694,6 +695,47 @@ func TestInput(t *testing.T) {
|
||||
wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
|
||||
t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no
|
||||
// secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets
|
||||
// (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one.
|
||||
noSecrets := movedMesh(true)
|
||||
noSecrets.own = nil
|
||||
seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatalf("the plans from the bus: %v", err)
|
||||
}
|
||||
controllers, err := plansIn(storeIn, "n1", compose.Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) {
|
||||
gj, _ := json.MarshalIndent(seat, "", " ")
|
||||
wj, _ := json.MarshalIndent(controllers, "", " ")
|
||||
t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures.
|
||||
items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil || len(unplaced) > 0 || len(items) == 0 {
|
||||
t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err)
|
||||
}
|
||||
for module, its := range items {
|
||||
for _, it := range its {
|
||||
if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" {
|
||||
t.Errorf("%s's item %+v is not placed", module, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for
|
||||
// mesh-provisioner and disk-load, the lead 2026-10-29).
|
||||
viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) {
|
||||
for _, body := range noSecrets.archives {
|
||||
return body, nil
|
||||
}
|
||||
return nil, errors.New("no archive")
|
||||
}), Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil || !reflect.DeepEqual(viaStates, items) {
|
||||
t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items)
|
||||
}
|
||||
|
||||
// And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
|
||||
// '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
|
||||
@@ -739,3 +781,72 @@ func TestInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStates are read states by name, key to value, as a module's runtime gives them.
|
||||
type mapStates map[string]map[string][]byte
|
||||
|
||||
func (m mapStates) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := m[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (m mapStates) Keys(state string) ([]string, error) {
|
||||
var out []string
|
||||
for k := range m[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// statesOf is b laid out as the states StatesReader reads.
|
||||
func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
|
||||
t.Helper()
|
||||
out := mapStates{}
|
||||
put := func(state, key string, raw []byte, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out[state] == nil {
|
||||
out[state] = map[string][]byte{}
|
||||
}
|
||||
out[state][key] = raw
|
||||
}
|
||||
placements, _ := b.Placements(ctx)
|
||||
for node, p := range placements {
|
||||
k, _ := delivery.PlacementKey(node)
|
||||
raw, err := delivery.EncodePlacement(p)
|
||||
put(PlacementRead, k, raw, err)
|
||||
}
|
||||
records, _ := b.Records(ctx)
|
||||
for node, r := range records {
|
||||
k, _ := delivery.HaveNodeKey(node)
|
||||
rec := r
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
|
||||
put(HaveRead, k, raw, err)
|
||||
}
|
||||
modules, _ := b.Modules(ctx)
|
||||
for name, m := range modules {
|
||||
raw, err := delivery.EncodeModule(m)
|
||||
put(ModulesRead, name, raw, err)
|
||||
var mf struct{ Module string }
|
||||
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
|
||||
k, _ := delivery.ManifestsKey(m.Manifest)
|
||||
raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
|
||||
put(ManifestsRead, k, raw, err)
|
||||
}
|
||||
for key, build := range b.builds {
|
||||
module, fp, _ := strings.Cut(key, " ")
|
||||
k, _ := delivery.BuilderBuildKey(module, fp)
|
||||
raw, err := delivery.EncodeBuilderBuild(*build)
|
||||
put(BuildsRead, k, raw, err)
|
||||
}
|
||||
for nm, values := range b.values {
|
||||
for k, v := range values {
|
||||
value, _ := json.Marshal(v)
|
||||
raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule})
|
||||
put(ResolvedRead, nm+"."+k, raw, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the
|
||||
// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR
|
||||
// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog
|
||||
// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more.
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var testdata embed.FS
|
||||
|
||||
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
|
||||
type Bus struct {
|
||||
Placement map[string]delivery.Placement
|
||||
Record map[string]delivery.NodeRecord
|
||||
Module map[string]delivery.Module
|
||||
Manifests map[string][]byte
|
||||
Value map[string]map[string]any // by node.module
|
||||
Own []compose.OwnSecretRow
|
||||
Builds map[string]*delivery.BuilderBuild // by "<module> <fp>"
|
||||
Archives map[string][]byte // by digest
|
||||
Serves map[string]map[string]any // another node's values, by node.module
|
||||
Held bool
|
||||
}
|
||||
|
||||
var _ frombus.Reader = (*Bus)(nil)
|
||||
|
||||
func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
return b.Placement, nil
|
||||
}
|
||||
func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil }
|
||||
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
|
||||
func (b *Bus) VaultAuthority(context.Context) string { return "" }
|
||||
|
||||
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
raw, ok := b.Manifests[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("no such manifest")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
return b.Value[node+"."+module], nil
|
||||
}
|
||||
|
||||
func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil }
|
||||
|
||||
func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
var out []compose.Credential
|
||||
var issued []compose.Issued
|
||||
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
|
||||
if b.Held {
|
||||
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
|
||||
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
|
||||
}
|
||||
}
|
||||
return out, issued, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
return b.Builds[module+" "+fp], nil
|
||||
}
|
||||
|
||||
func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) {
|
||||
body, ok := b.Archives[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("the store holds no such blob")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
for _, k := range keys {
|
||||
if v, ok := b.Serves[node+"."+module][k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Node is the one node of the mesh Provisioned composes.
|
||||
const Node = "n1"
|
||||
|
||||
// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's
|
||||
// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans
|
||||
// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider.
|
||||
func Provisioned() (*Bus, error) {
|
||||
read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) }
|
||||
resticRaw, err := read("catalogue-c05aa5e6-restic.module.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var restic map[string]any
|
||||
if err := json.Unmarshal(resticRaw, &restic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
restic["requires"] = []string{"postgres-database"}
|
||||
if resticRaw, err = json.Marshal(restic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"serves":{"artifact-store":{"port":5000}}}`)
|
||||
postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
|
||||
`"serves":{"postgres-database":{"port":5432}}}`)
|
||||
|
||||
var packed bytes.Buffer
|
||||
zw := gzip.NewWriter(&packed)
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
|
||||
text, err := read("catalogue-c05aa5e6-restic.files/" + f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tw.Write(text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archive := delivery.ManifestDigest(packed.Bytes())
|
||||
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
fps := FPs()
|
||||
b := &Bus{
|
||||
Placement: map[string]delivery.Placement{Node: {Node: Node,
|
||||
Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
|
||||
Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
|
||||
Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
|
||||
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
|
||||
Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
|
||||
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
|
||||
Builds: map[string]*delivery.BuilderBuild{
|
||||
"restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{
|
||||
{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}},
|
||||
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw),
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle",
|
||||
Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}},
|
||||
"store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
|
||||
"postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)},
|
||||
},
|
||||
Archives: map[string][]byte{archive: packed.Bytes()},
|
||||
Held: true,
|
||||
}
|
||||
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} {
|
||||
b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)}
|
||||
b.Manifests[delivery.ManifestDigest(raw)] = raw
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// FPs are the fingerprints Provisioned's modules are built at.
|
||||
func FPs() map[string]string {
|
||||
return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
|
||||
"store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
)
|
||||
|
||||
// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the
|
||||
// provisioner and names its postgres-database, provided by postgres on n1.
|
||||
func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
b, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restic, handed := plans["restic"]
|
||||
if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" ||
|
||||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
|
||||
t.Fatalf("the plans are %+v", plans)
|
||||
}
|
||||
}
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"module": "mesh-provisioner-client",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-provisioner",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"provisioner-broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-provisioner-client",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-provisioner-client",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-provisioner-client",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "client",
|
||||
"type": "process",
|
||||
"name": "mesh-provisioner-client",
|
||||
"artifact": "client",
|
||||
"run": [
|
||||
"./mesh-provisioner-client"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
|
||||
"MESH_PROVISIONER_STATE": "${dir:state}"
|
||||
},
|
||||
"user": "mesh-provisioner-client",
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "client",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-provisioner-client",
|
||||
"binary": "mesh-provisioner-client"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
|
||||
${backup:lines}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
|
||||
${backup:data}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
|
||||
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
|
||||
daily=${config:keep-daily}
|
||||
weekly=${config:keep-weekly}
|
||||
monthly=${config:keep-monthly}
|
||||
hand=${config:keep-hand-taken-days}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"module": "restic",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-backup",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"backed-up",
|
||||
"now",
|
||||
"restore"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"backed-up"
|
||||
],
|
||||
"tools": [
|
||||
"backup_health",
|
||||
"backup_delete_retired",
|
||||
"backup_deleted"
|
||||
],
|
||||
"own-secrets": {
|
||||
"repository": "${dir:state}/repository.secret"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "repository",
|
||||
"path": "${dir:repository}",
|
||||
"class": "rebuildable",
|
||||
"backup": "none",
|
||||
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"path": "${dir:state}",
|
||||
"class": "cache",
|
||||
"why": "what the last nights and measurements were; the next night writes it again"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"keep-daily": {
|
||||
"type": "number",
|
||||
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 14
|
||||
},
|
||||
"keep-weekly": {
|
||||
"type": "number",
|
||||
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 8
|
||||
},
|
||||
"keep-monthly": {
|
||||
"type": "number",
|
||||
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 6
|
||||
},
|
||||
"keep-hand-taken-days": {
|
||||
"type": "number",
|
||||
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 7
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "repository",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/backups.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/backups.conf"
|
||||
},
|
||||
{
|
||||
"id": "data-declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/data.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/data.conf"
|
||||
},
|
||||
{
|
||||
"id": "retention",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/retention.conf",
|
||||
"mode": "0644",
|
||||
"trusted": true,
|
||||
"template": "files/retention.conf"
|
||||
},
|
||||
{
|
||||
"id": "tool",
|
||||
"type": "package",
|
||||
"package": "restic"
|
||||
},
|
||||
{
|
||||
"id": "sqlite",
|
||||
"type": "package",
|
||||
"package": "sqlite"
|
||||
},
|
||||
{
|
||||
"id": "night",
|
||||
"type": "process",
|
||||
"name": "restic-night",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"night"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "backup_health",
|
||||
"interval": "60s",
|
||||
"timeout": "20s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "measure",
|
||||
"type": "process",
|
||||
"name": "restic-measure",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"measure"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/restic-backups",
|
||||
"binary": "restic-backups",
|
||||
"loads": [
|
||||
"restic-backups"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package frombus
|
||||
|
||||
// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
|
||||
// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
|
||||
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
|
||||
// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
|
||||
// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
|
||||
// runtime states and an HTTP fetch are compose/fromruntime's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The states a module names in its manifest's `reads` to compose through StatesReader.
|
||||
const (
|
||||
PlacementRead = delivery.PlacementRead
|
||||
ModulesRead = delivery.IndexerModule + ".modules"
|
||||
ManifestsRead = delivery.IndexerModule + ".manifests"
|
||||
BuildsRead = delivery.BuilderModule + ".builds"
|
||||
HaveRead = "node-engine.have"
|
||||
ResolvedRead = "mesh-config.resolved"
|
||||
)
|
||||
|
||||
// StatesReads are those reads, in a manifest's order.
|
||||
var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
|
||||
|
||||
// States is a module's read states as its runtime gives them: a key's value, false when it has none.
|
||||
type States interface {
|
||||
Get(state, key string) (value []byte, found bool, err error)
|
||||
Keys(state string) ([]string, error)
|
||||
}
|
||||
|
||||
// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP).
|
||||
type Fetch func(ctx context.Context, url string) ([]byte, error)
|
||||
|
||||
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
|
||||
func StatesReader(s States, fetch Fetch) Reader { return statesReader{s: s, fetch: fetch} }
|
||||
|
||||
type statesReader struct {
|
||||
s States
|
||||
fetch Fetch
|
||||
}
|
||||
|
||||
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
|
||||
keys, err := r.s.Keys(read)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", read, err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
v, found, err := r.s.Get(read, k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: %w", read, k, err)
|
||||
}
|
||||
if found {
|
||||
visit(k, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
out := map[string]delivery.Placement{}
|
||||
err := r.each(PlacementRead, func(k string, v []byte) {
|
||||
if node, err := delivery.ParsePlacementKey(k); err == nil {
|
||||
if p, err := delivery.DecodePlacement(v); err == nil && p.Node == node {
|
||||
out[node] = p
|
||||
}
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
|
||||
out := map[string]delivery.NodeRecord{}
|
||||
err := r.each(HaveRead, func(k string, v []byte) {
|
||||
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
|
||||
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
|
||||
out[node] = *h.Record
|
||||
}
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
|
||||
out := map[string]delivery.Module{}
|
||||
err := r.each(ModulesRead, func(k string, v []byte) {
|
||||
if module, err := delivery.ParseModulesKey(k); err == nil {
|
||||
if m, err := delivery.DecodeModule(v); err == nil {
|
||||
out[module] = m
|
||||
}
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
key, err := delivery.ManifestsKey(digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, found, err := r.s.Get(ManifestsRead, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
|
||||
}
|
||||
m, err := delivery.DecodeManifest(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(m.JSON), nil
|
||||
}
|
||||
|
||||
func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
prefix := node + "." + module + "."
|
||||
err := r.each(ResolvedRead, func(k string, v []byte) {
|
||||
name, ok := strings.CutPrefix(k, prefix)
|
||||
if !ok || strings.Contains(name, ".") {
|
||||
return
|
||||
}
|
||||
if value, laid := catalogue.LaidValue(v); laid {
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, found, err := r.s.Get(BuildsRead, key)
|
||||
if err != nil || !found {
|
||||
return nil, err
|
||||
}
|
||||
b, err := delivery.DecodeBuilderBuild(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// archiveWithin bounds one read of a templates archive.
|
||||
const archiveWithin = 30 * time.Second
|
||||
|
||||
func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) {
|
||||
if r.fetch == nil {
|
||||
return nil, fmt.Errorf("this holder reads no archive (%s)", url)
|
||||
}
|
||||
fetching, cancel := context.WithTimeout(ctx, archiveWithin)
|
||||
defer cancel()
|
||||
body, err := r.fetch(fetching, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") {
|
||||
return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) {
|
||||
all, err := r.Values(ctx, node, module)
|
||||
var out map[string]any
|
||||
for _, k := range keys {
|
||||
if v, ok := all[k]; ok {
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a
|
||||
// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched
|
||||
// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command.
|
||||
package fromruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
)
|
||||
|
||||
// States are a bundle's read states through its runtime.
|
||||
type States struct{}
|
||||
|
||||
// Get is a key's value, false when it has none.
|
||||
func (States) Get(state, key string) ([]byte, bool, error) {
|
||||
e, err := stdio.State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}
|
||||
|
||||
// Keys are a state's keys.
|
||||
func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
// Reader is frombus's Reader over this bundle's read states, fetching archives over HTTP.
|
||||
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
|
||||
|
||||
// HTTPFetch reads an archive over HTTP, at most 256 MiB.
|
||||
func HTTPFetch(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s answered %s", url, resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 256<<20))
|
||||
}
|
||||
@@ -210,6 +210,12 @@ type Node struct {
|
||||
// (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own
|
||||
// never drops it.
|
||||
Held map[string]bool
|
||||
// RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the
|
||||
// node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a
|
||||
// build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no
|
||||
// bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost
|
||||
// the folded systemd-resolved, novox's the folded nats).
|
||||
RunsTools map[string][]catalogue.Bundle
|
||||
}
|
||||
|
||||
// Place is where a node is, as its placement says (inv.Overlay).
|
||||
|
||||
@@ -333,3 +333,19 @@ func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the
|
||||
// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load
|
||||
// reads the list from the bus with no composed file. Unplaced names each item left out and why.
|
||||
func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.planFor(node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rendered, err := c.render(node, Planning)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return resolved.Plan.DataItems(rendered.Rendering)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
|
||||
return Rendered{}, err
|
||||
}
|
||||
r.Needed = needed
|
||||
if mode == Foreseeing || len(foreseenOwn) > 0 {
|
||||
if mode == Foreseeing || mode == Planning || len(foreseenOwn) > 0 {
|
||||
r.Foreseen = foreseenOwn
|
||||
}
|
||||
var names []string
|
||||
|
||||
@@ -141,11 +141,14 @@ const (
|
||||
RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary
|
||||
RuleGrant = 6 // the module's credential for F exists (grants.<module>.<fp>)
|
||||
RuleComposed = 7 // the module's composition for that node at F exists (mesh-controller_composed, ADR 0539 §6)
|
||||
// RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4): a want's
|
||||
// words name the key and its state that hold it, as "the database of keycloak on novox is asked of postgres".
|
||||
RuleProvisions = 8
|
||||
)
|
||||
|
||||
func checkRule(rule int, of string) error {
|
||||
if rule < RuleBuildDone || rule > RuleComposed {
|
||||
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the seven (1 to 7)", of, rule)
|
||||
if rule < RuleBuildDone || rule > RuleProvisions {
|
||||
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the eight (1 to 8)", of, rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestAssignerValuesRefused(t *testing.T) {
|
||||
{Removed: true, FP: fp2of('a')},
|
||||
{FP: fp2of('a'), Newest: fp2of('b')},
|
||||
{FP: fp2of('a'), Newest: fp2of('a'), Rule: 3},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 9},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Hold: true},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Put: true, Newest: fp2of('b'), Rule: 3},
|
||||
{Removed: true, Put: true},
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **The control records' layouts, out of the controller** (novox/hq ADR 0565 clauses 2, 3 and 6; issue 1953 L5-S2): what
|
||||
// the indexer keeps of the catalogue's predefined list and of each open pull request's config declarations, which
|
||||
// mesh-config reads in place of the controller's mirrors and `pull-declares`; and each node's declared packages, which
|
||||
// its engine writes in place of the controller's mirror.
|
||||
|
||||
// The indexer's two states (clause 2).
|
||||
const (
|
||||
// IndexerPredefinedState holds one key, PredefinedListKey: the catalogue's predefined.json at the newest merge taken.
|
||||
IndexerPredefinedState = "predefined"
|
||||
PredefinedListKey = "list"
|
||||
// IndexerConfigCandidatesState holds one key per open pull request (ConfigCandidateKey).
|
||||
IndexerConfigCandidatesState = "config-candidates"
|
||||
)
|
||||
|
||||
// PredefinedKept is the indexer's key `predefined.list`: the list whole, as predefined.json holds it, with the merge it
|
||||
// was read at. A merge older than the one the key names never replaces it (ADR 0348 §7).
|
||||
type PredefinedKept struct {
|
||||
V int `json:"v"`
|
||||
Commit string `json:"commit"`
|
||||
At time.Time `json:"at"`
|
||||
List json.RawMessage `json:"list"`
|
||||
}
|
||||
|
||||
// ConfigDeclaration is one config key a module's manifest declares, in the layout of the controller's declared-config
|
||||
// mirror (mesh-controller internal/inventory DeclaredMirrored) unchanged, so mesh-config reads the indexer's as it read
|
||||
// the mirror's.
|
||||
type ConfigDeclaration struct {
|
||||
Kind string `json:"kind"`
|
||||
Default json.RawMessage `json:"default"`
|
||||
Why string `json:"why"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum json.RawMessage `json:"enum,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
Trusted bool `json:"trusted,omitempty"`
|
||||
Predefined string `json:"predefined,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Runtime bool `json:"runtime,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigCandidate is one open pull request's key of `config-candidates`: its head, and per module the config
|
||||
// declarations the head's manifests add or change against the trunk's, an empty set when none. Written at each new
|
||||
// head and each trunk merge that changes a module it names; deleted when the pull request merges or closes.
|
||||
type ConfigCandidate struct {
|
||||
V int `json:"v"`
|
||||
Pull string `json:"pull"`
|
||||
Head string `json:"head"`
|
||||
Declares map[string]map[string]ConfigDeclaration `json:"declares"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// ConfigCandidateKey is a pull request's key, `owner/repository#number` with `/` and `#` as dots, the form mesh-config
|
||||
// keeps a pull request's values under.
|
||||
func ConfigCandidateKey(pull string) (string, error) {
|
||||
owner, rest, ok := strings.Cut(strings.TrimSpace(pull), "/")
|
||||
repo, number, ok2 := strings.Cut(rest, "#")
|
||||
if !ok || !ok2 || owner == "" || repo == "" || number == "" || strings.ContainsAny(owner+repo+number, ".*> ") {
|
||||
return "", fmt.Errorf("delivery: %q is not a pull request, owner/repository#number", pull)
|
||||
}
|
||||
return owner + "." + repo + "." + number, nil
|
||||
}
|
||||
|
||||
// The node-engine's packages (clause 6): its state `packages`, one key, the node's name, written by that node's engine
|
||||
// alone.
|
||||
const EnginePackagesState = "packages"
|
||||
|
||||
// PackageDeclared is one package a node's modules declare, the controller's declared-packages mirror's entry unchanged
|
||||
// (mesh-controller internal/inventory PackageDeclared): its name, the modules that declare it that way, sorted, and
|
||||
// whether they declare it absent.
|
||||
type PackageDeclared struct {
|
||||
Package string `json:"package"`
|
||||
Modules []string `json:"modules"`
|
||||
Absent bool `json:"absent"`
|
||||
}
|
||||
|
||||
// DeclaredPackages is one node's key of `packages`, the mirror's value unchanged: its packages sorted by name, absent
|
||||
// after present. Fixed fields and no time, so the same declaration is the same bytes (ADR 0289 §1).
|
||||
type DeclaredPackages struct {
|
||||
Node string `json:"node"`
|
||||
Packages []PackageDeclared `json:"packages"`
|
||||
}
|
||||
|
||||
// mesh-config's state of the approved values held for a pull request (novox/hq ADR 0565 clause 5): read by the checker
|
||||
// and the assigner's composition check, which lay a pull request's own held values over its modules' values.
|
||||
const (
|
||||
MeshConfigKeptState = "kept"
|
||||
MeshConfigKeptRead = "mesh-config." + MeshConfigKeptState
|
||||
)
|
||||
|
||||
// KeptValue is one approved held value, mesh-config's layout unchanged (modules/mesh-config pulls.go): kept apart from
|
||||
// the values mesh-config resolves, laid only over the composition of its own pull request at its head.
|
||||
type KeptValue struct {
|
||||
Pull string `json:"pull"`
|
||||
Head string `json:"head"`
|
||||
Digest string `json:"declaration"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Key string `json:"key"`
|
||||
// Value is the value held, as the operator approved it.
|
||||
Value json.RawMessage `json:"value"`
|
||||
By string `json:"by"`
|
||||
Via string `json:"via"`
|
||||
Why string `json:"why"`
|
||||
// Approved is who approved it, as the proposal kept it.
|
||||
Approved string `json:"approved"`
|
||||
Proposal string `json:"proposal"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HeldValueKey is a held value's key in `kept`: the pull request's key (ConfigCandidateKey's form), the node, the module and
|
||||
// the config key, joined by dots, as mesh-config writes it.
|
||||
func HeldValueKey(pull, node, module, key string) (string, error) {
|
||||
p, err := ConfigCandidateKey(pull)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p + "." + node + "." + module + "." + key, nil
|
||||
}
|
||||
|
||||
// The assigner's state of a person's upgrade policy, one key per module (clause 7; catalogue.PersonPolicy).
|
||||
const AssignerPoliciesState = "policies"
|
||||
@@ -0,0 +1,150 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ADR 0565 clauses 2 and 6 (issue 1953 L5-S2): the control records' layouts read what their writers write. The
|
||||
// packages value is the controller's declared-packages mirror's, as its writer's own test pins it (mesh-controller
|
||||
// internal/inventory/declared_packages_test.go at 0fbb5ef3); the config declaration is its declared-config mirror's
|
||||
// (DeclaredMirrored); a pull request's key is mesh-config's form.
|
||||
func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
raw string
|
||||
into any
|
||||
}{
|
||||
{"declared packages", `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` +
|
||||
`{"package":"nano","modules":["web"],"absent":true}]}`, &DeclaredPackages{}},
|
||||
{"a config candidate", `{"v":1,"pull":"novox/mesh-catalog#42","head":"abc","declares":{"mail":{"quota":{"kind":"config",` +
|
||||
`"default":10,"why":"a quota","type":"integer","minimum":1}}},"at":"2026-10-29T12:00:00Z"}`, &ConfigCandidate{}},
|
||||
{"a held value", `{"pull":"novox/mesh-catalog#42","head":"abc","declaration":"sha256:d","node":"novox","module":"mail",` +
|
||||
`"key":"quota","value":20,"by":"person.jochen","via":"telegram","why":"more room","approved":"the operator",` +
|
||||
`"proposal":"p-1","at":"2026-10-29T12:00:00Z"}`, &KeptValue{}},
|
||||
// A node's health report as mesh-host 65fbc1e6 encodes it (its test's look, struct order kept).
|
||||
{"a node's health report", `{"v":1,"node":"anchor","network":{"state":"unhealthy","since":"2026-10-29T20:00:00Z",` +
|
||||
`"parts":[{"part":"resolv-conf","state":"unhealthy","writer":"NetworkManager","owner":"dnsmasq","since":"2026-10-29T20:00:00Z"}]},` +
|
||||
`"peers":[{"key":"A=","addresses":["10.10.0.2/32"],"seen":false},{"key":"B=","seen":true}],` +
|
||||
`"units":{"state":"degraded","failed":[{"unit":"cups.service","scope":"system","since":"2026-10-29T20:00:00Z"}]},` +
|
||||
`"relogin":[{"account":"jochen","module":"brother","resource":"brother.scanner","reason":"relogin needed: the session began before scanner","since":"2026-10-29T20:00:00Z"}],` +
|
||||
`"found":[{"module":"photos","resource":"photos.library","path":"/srv/photos","reason":"used as found: owned by root","since":"2026-10-29T20:00:00Z"}],` +
|
||||
`"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||
// The engine's verdict on an account declared never to become root (ADR 0266), and its contract.
|
||||
{"a node's health report, the agent account judged", `{"v":1,"node":"anchor","root":[{"account":"agent",` +
|
||||
`"module":"claude-code","resource":"claude-code.agent","state":"healthy","since":"2026-10-29T20:00:00Z"}],` +
|
||||
`"contract":13,"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||
// A part pointing at another machine names it (issue 774): the hub's tunnel, held at the hub's machine.
|
||||
{"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` +
|
||||
`"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` +
|
||||
`"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||
{"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}},
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(c.raw), c.into); err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
again, _ := json.Marshal(c.into)
|
||||
var want, got any
|
||||
_ = json.Unmarshal([]byte(c.raw), &want)
|
||||
_ = json.Unmarshal(again, &got)
|
||||
if string(jsonOf(want)) != string(jsonOf(got)) || ((c.name == "declared packages" || strings.HasPrefix(c.name, "a node's health report")) && string(again) != c.raw) {
|
||||
t.Errorf("%s reads back as %s", c.name, again)
|
||||
}
|
||||
}
|
||||
if a, b := (NodeHealth{Node: "anchor", At: time.Now()}), (NodeHealth{Node: "anchor"}); !SameNodeHealth(a, b) ||
|
||||
SameNodeHealth(a, NodeHealth{Node: "anchor", Peers: []PeerSeen{{Key: "A="}}}) {
|
||||
t.Error("two reports differing only in when they were written are not the same, or differing ones are")
|
||||
}
|
||||
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
|
||||
t.Error("a report naming no node was encoded")
|
||||
}
|
||||
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
|
||||
held := SeatHoldersOf(map[string]Placement{
|
||||
"ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
|
||||
"novox": {Node: "novox", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "mesh-broker", Scope: "mesh", Module: "nats"},
|
||||
{Claim: "photo-gallery", Scope: "mesh", Module: "photos"}}},
|
||||
})
|
||||
if o := held["photo-gallery"]; !o.Outside || len(o.Holders) != 1 || held["mesh-broker"].Outside {
|
||||
t.Errorf("a claim outside the set is %+v", o)
|
||||
}
|
||||
keys := map[string][]byte{}
|
||||
for k, v := range held {
|
||||
keys[k], _ = json.Marshal(v)
|
||||
}
|
||||
if h, err := HoldersOf(keys, "mesh-dns-resolver", ""); err != nil || len(h) != 2 || h[0].Node != "ace" {
|
||||
t.Errorf("a replicated seat's holders are %v, %v", h, err)
|
||||
}
|
||||
if h, _ := HoldersOf(keys, "node-files", "ace"); len(h) != 1 || h[0].Module != "mesh-files" || len(held) != 4 {
|
||||
t.Errorf("a node seat's holder is %v, keys %v", h, held)
|
||||
}
|
||||
// ADR 0571 clauses 4, 5: the modules and the machines, from the indexer's and the placement seat's keys.
|
||||
if m, err := ModuleNames(map[string][]byte{"letta": nil, "gitea": nil}); err != nil || strings.Join(m, ",") != "gitea,letta" {
|
||||
t.Errorf("the modules are %v, %v", m, err)
|
||||
}
|
||||
if _, err := ModuleNames(map[string][]byte{"a.b": nil}); err == nil {
|
||||
t.Error("a key that is no module's was read as one")
|
||||
}
|
||||
if n := MachineNames(map[string][]byte{"placement.novox": nil, "placement.ace": nil, "proposal.p1": nil}); strings.Join(n, ",") != "ace,novox" {
|
||||
t.Errorf("the machines are %v", n)
|
||||
}
|
||||
if h, _ := HoldersOf(keys, "node-files", "novox"); h != nil {
|
||||
t.Errorf("a node seat nobody holds there is held by %v", h)
|
||||
}
|
||||
// ADR 0571 clause 1: the builder's log, a start and an end, and the duration between them.
|
||||
at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
|
||||
start := BuildLogEntry{Event: BuildStartedEvent, Module: "letta", FP: "src2:a", Source: "abc", Repository: "novox/mesh-catalog",
|
||||
Directory: "modules/letta", Commit: "abc123", Node: "ace", At: at}
|
||||
end := start
|
||||
end.Event, end.At, end.Outcome, end.Manifest, end.Against = BuildEndedEvent, at.Add(3*time.Minute), "done", "sha256:m", []string{"artifact-store://base/image@sha256:b"}
|
||||
end.Made = []BuiltArtifact{{Name: "image", Kind: "image", Digest: "sha256:1", Reference: "artifact-store://letta/image@sha256:1"}}
|
||||
for _, e := range []BuildLogEntry{start, end} {
|
||||
raw, err := EncodeBuildLogEntry(e)
|
||||
back, derr := DecodeBuildLogEntry(raw)
|
||||
if err != nil || derr != nil || back.Event != e.Event || back.Outcome != e.Outcome || back.Directory != "modules/letta" ||
|
||||
back.Repository != "novox/mesh-catalog" || back.Commit != "abc123" {
|
||||
t.Errorf("%s read back as %+v, %v %v", e.Event, back, err, derr)
|
||||
}
|
||||
}
|
||||
if d, ok := BuildDuration(start, end); !ok || d != 3*time.Minute {
|
||||
t.Errorf("the build took %s, %v", d, ok)
|
||||
}
|
||||
older := end
|
||||
older.At, older.FP = at.Add(-time.Hour), "src2:b"
|
||||
other := end
|
||||
other.Module = "mesh-catalog"
|
||||
if n := NewestPerModule([]BuildLogEntry{older, end, other}); n["letta"].FP != "src2:a" || len(n) != 2 {
|
||||
t.Errorf("each module's newest is %v", n)
|
||||
}
|
||||
if e := EndedSince([]BuildLogEntry{end, start, older}, at.Add(-2*time.Hour)); len(e) != 2 || e[0].FP != "src2:b" {
|
||||
t.Errorf("the ended builds since are %v", e)
|
||||
}
|
||||
if k, err := BuildLogKey("letta"); k != "letta" || err != nil || BuildLogRead != "mesh-delivery-builder.build-log" {
|
||||
t.Errorf("the key is %q, %v", k, err)
|
||||
}
|
||||
if _, err := BuildLogKey("a.b"); err == nil {
|
||||
t.Error("a module with a dot was taken as a key")
|
||||
}
|
||||
for _, edit := range []func(*BuildLogEntry){func(e *BuildLogEntry) { e.Outcome = "" }, func(e *BuildLogEntry) { e.Commit = "" },
|
||||
func(e *BuildLogEntry) { e.Event, e.Outcome, e.Against = BuildStartedEvent, "", nil }} {
|
||||
bad := end
|
||||
edit(&bad)
|
||||
if _, err := EncodeBuildLogEntry(bad); err == nil {
|
||||
t.Errorf("%+v was encoded", bad)
|
||||
}
|
||||
}
|
||||
if k, err := HeldValueKey("novox/mesh-catalog#42", "novox", "mail", "quota"); k != "novox.mesh-catalog.42.novox.mail.quota" || err != nil {
|
||||
t.Errorf("a held value's key is %q, %v", k, err)
|
||||
}
|
||||
for pull, want := range map[string]string{"novox/mesh-catalog#42": "novox.mesh-catalog.42", "x#1": "", "a/b": "", "a/b.c#1": ""} {
|
||||
if got, err := ConfigCandidateKey(pull); got != want || (want == "") != (err != nil) {
|
||||
t.Errorf("%s keyed %q, %v", pull, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonOf(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
@@ -33,6 +33,22 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) {
|
||||
if got.Manifests["a"] != oddManifest || !SameForge(got, withV(f)) {
|
||||
t.Errorf("the message changed on the way: %q", got.Manifests["a"])
|
||||
}
|
||||
// ADR 0565 §2 (issue 1953 D1): a merged message carries the predefined list exact, or says the merge holds none; a
|
||||
// pull message carries neither.
|
||||
f.Predefined = []byte(`{"domain":{"type":"string"}}`)
|
||||
if data, err := EncodeForge(f); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if got, err := DecodeForge(data); err != nil || string(got.Predefined) != `{"domain":{"type":"string"}}` {
|
||||
t.Errorf("the predefined list read back as %s, %v", got.Predefined, err)
|
||||
}
|
||||
f.Predefined, f.PredefinedRead = nil, true
|
||||
if _, err := EncodeForge(f); err != nil {
|
||||
t.Errorf("a merge holding no predefined list: %v", err)
|
||||
}
|
||||
pull := Forge{Kind: ForgePull, Repository: "novox/mesh-sdk", Number: 1, Head: "h", Action: PullOpened, PredefinedRead: true}
|
||||
if _, err := EncodeForge(pull); err == nil || !strings.Contains(err.Error(), "only a merged message") {
|
||||
t.Errorf("a pull message carrying the predefined list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func withV(f Forge) Forge { f.V = Version; return f }
|
||||
@@ -50,6 +66,9 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) {
|
||||
// No repository holds placement (ADR 0567 §11): a message carrying placement files is refused, on a merge or a pull.
|
||||
"placement files": {func(f *Forge) { f.RetiredPlacements = []byte(`{"anchor":"{}"}`) }, "mesh-placement"},
|
||||
"placements read": {func(f *Forge) { f.RetiredRead = true }, "mesh-placement"},
|
||||
// ADR 0565 §2 (issue 1953 D1): the predefined list is one JSON object, said present or absent, never both.
|
||||
"predefined not an object": {func(f *Forge) { f.Predefined = []byte(`["domain"]`) }, "one JSON object"},
|
||||
"predefined both": {func(f *Forge) { f.Predefined = []byte(`{}`); f.PredefinedRead = true }, "says the merge holds none"},
|
||||
} {
|
||||
f := mergedWithInputs()
|
||||
tc.mut(&f)
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// **Who holds which seat, and the builder's log** (novox/hq ADR 0571 clauses 1 and 4, issue 1955).
|
||||
|
||||
// SeatHoldersState is the state the seat mesh-placement's holder writes of who holds which seat on which node (clause 4):
|
||||
// one key per seat held once for the mesh, `<seat>`, and one per node of a seat held on every node, `<seat>.<node>`.
|
||||
const (
|
||||
SeatHoldersState = "seat-holders"
|
||||
SeatHoldersBucket = AssignerModule + "_" + SeatHoldersState
|
||||
SeatHoldersRead = AssignerModule + "." + SeatHoldersState
|
||||
)
|
||||
|
||||
// SeatHolder is one module holding a seat on a node.
|
||||
type SeatHolder struct {
|
||||
Module string `json:"module"`
|
||||
Node string `json:"node"`
|
||||
}
|
||||
|
||||
// SeatHolders is one key of seat-holders: the seat, its scope, the node for a node seat's key, and its holders
|
||||
// (several only for a replicated mesh seat), sorted. Outside says the claim names no seat of the set (catalogue.Seats):
|
||||
// a module registered before the set closed, or built outside the indexed repositories, keeps its claim listed, as the
|
||||
// controller's `seats` listed it under outside.
|
||||
type SeatHolders struct {
|
||||
V int `json:"v"`
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Outside bool `json:"outside,omitempty"`
|
||||
Holders []SeatHolder `json:"holders"`
|
||||
}
|
||||
|
||||
// SeatHoldersKey is a seat's key: `<seat>` for a seat held once for the mesh (node empty), `<seat>.<node>` for a node
|
||||
// seat on one node.
|
||||
func SeatHoldersKey(seat, node string) (string, error) {
|
||||
if err := checkName("seat", seat); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if node == "" {
|
||||
return seat, nil
|
||||
}
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return seat + "." + node, nil
|
||||
}
|
||||
|
||||
// SeatHoldersOf is the state composed from every node's placement (ADR 0567 §1): each holding by the seat it claims,
|
||||
// keyed as SeatHoldersKey says, with the module holding it and its node. A claim naming a seat's former name is keyed by
|
||||
// the seat's name (ADR 0122); one naming no seat is kept, marked Outside. Deterministic, so a writer writes only a key
|
||||
// that differs.
|
||||
func SeatHoldersOf(placements map[string]Placement) map[string]SeatHolders {
|
||||
out := map[string]SeatHolders{}
|
||||
nodes := make([]string, 0, len(placements))
|
||||
for n := range placements {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
for _, n := range nodes {
|
||||
for _, h := range placements[n].Holds {
|
||||
node := ""
|
||||
if h.Scope == "node" {
|
||||
node = n
|
||||
}
|
||||
seat, known := catalogue.SeatNamed(h.Claim)
|
||||
name := h.Claim
|
||||
if known {
|
||||
name = seat.Name
|
||||
}
|
||||
key, err := SeatHoldersKey(name, node)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sh := out[key]
|
||||
sh.V, sh.Seat, sh.Scope, sh.Node, sh.Outside = Version, name, h.Scope, node, !known
|
||||
sh.Holders = append(sh.Holders, SeatHolder{Module: h.Module, Node: n})
|
||||
out[key] = sh
|
||||
}
|
||||
}
|
||||
for k, sh := range out {
|
||||
sort.Slice(sh.Holders, func(i, j int) bool {
|
||||
return sh.Holders[i].Node+"/"+sh.Holders[i].Module < sh.Holders[j].Node+"/"+sh.Holders[j].Module
|
||||
})
|
||||
out[k] = sh
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HoldersOf reads who holds seat (on node, for a node seat) from the state's keys as a reader watched them: the node's
|
||||
// key first, then the seat's. None when nobody holds it.
|
||||
func HoldersOf(keys map[string][]byte, seat, node string) ([]SeatHolder, error) {
|
||||
for _, n := range []string{node, ""} {
|
||||
key, err := SeatHoldersKey(seat, n)
|
||||
if err != nil {
|
||||
if n == "" {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
raw, ok := keys[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var sh SeatHolders
|
||||
if err := json.Unmarshal(raw, &sh); err != nil {
|
||||
return nil, fmt.Errorf("delivery: the holders of %s are not readable: %w", key, err)
|
||||
}
|
||||
if sh.V != Version {
|
||||
return nil, fmt.Errorf("delivery: the holders of %s are of version %d, and this reads %d", key, sh.V, Version)
|
||||
}
|
||||
return sh.Holders, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The builder's log (clause 1; the lead and the operator, 2026-10-29): the builder's own module log (ADR 0297) `build-log`,
|
||||
// kept 30 days (max-age-days), one entry under the key `<module>` when a build of a fingerprint starts and one when it
|
||||
// ends, so the newest entry of a key is the module's newest start or end. The builder's tools read it through the
|
||||
// runtime's log verbs; another module reads it by naming BuildLogRead in its manifest's `reads`: a bundle through the
|
||||
// runtime (stdio.Log(BuildLogRead)), a process on its own credential (process.Conn.ReadLog), each by newest per key,
|
||||
// from a time, or following new entries. The catalogue module asks the builder's tool BuildsSinceTool instead.
|
||||
const (
|
||||
BuilderLog = "build-log"
|
||||
BuildLogRead = BuilderModule + "." + BuilderLog
|
||||
BuildStartedEvent = "build.started"
|
||||
BuildEndedEvent = "build.ended"
|
||||
// BuilderLogDays is how long the log keeps its entries.
|
||||
BuilderLogDays = 30
|
||||
// BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its
|
||||
// argument `since` (RFC 3339), its answer BuildsSince.
|
||||
BuildsSinceTool = "builder_builds_since"
|
||||
)
|
||||
|
||||
// BuildLogKey is the key of a module's entries in the builder's log: the module's name, refused when a log key could not
|
||||
// hold it.
|
||||
func BuildLogKey(module string) (string, error) {
|
||||
if err := checkName("module", module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// BuildLogEntry is one entry of the builder's log: the module, the fingerprint, the source revision, where the module is
|
||||
// built from (its repository, its directory there, the commit and the ref), the build node and the time; at the end,
|
||||
// the outcome, the manifest built (its digest, read from the indexer's state `manifests` under ManifestsKey), what it was
|
||||
// built against (the bases' references, as the controller's built-before said them) and each artifact made
|
||||
// (BuiltArtifact, the done key's: its name, kind, digest and reference). The repository, directory and commit are what
|
||||
// the indexer's PathOf, the outside-built registrations and the assigner's running commits read; the manifest, against
|
||||
// and made what the catalogue module registers a build from (the lead, 2026-10-29).
|
||||
type BuildLogEntry struct {
|
||||
V int `json:"v"`
|
||||
Event string `json:"event"`
|
||||
Module string `json:"module"`
|
||||
FP string `json:"fp"`
|
||||
Source string `json:"source"`
|
||||
Repository string `json:"repository"`
|
||||
Directory string `json:"directory"`
|
||||
Commit string `json:"commit"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Node string `json:"node"`
|
||||
At time.Time `json:"at"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Manifest string `json:"manifest,omitempty"`
|
||||
Against []string `json:"against,omitempty"`
|
||||
Made []BuiltArtifact `json:"made,omitempty"`
|
||||
}
|
||||
|
||||
// BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held
|
||||
// more than one answer carries (then ask again from the last one's time).
|
||||
type BuildsSince struct {
|
||||
Since time.Time `json:"since"`
|
||||
Builds []BuildLogEntry `json:"builds"`
|
||||
More bool `json:"more,omitempty"`
|
||||
}
|
||||
|
||||
// NewestPerModule is each module's newest entry of entries (one kind, read from the last per subject or whole), by
|
||||
// module: the reader's helper for the five newest and the running commits.
|
||||
func NewestPerModule(entries []BuildLogEntry) map[string]BuildLogEntry {
|
||||
out := map[string]BuildLogEntry{}
|
||||
for _, e := range entries {
|
||||
if have, ok := out[e.Module]; !ok || e.At.After(have.At) {
|
||||
out[e.Module] = e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EndedSince is, of entries, the ended builds at or after since, oldest first: the reader's helper for history and for
|
||||
// BuildsSinceTool's answer.
|
||||
func EndedSince(entries []BuildLogEntry, since time.Time) []BuildLogEntry {
|
||||
var out []BuildLogEntry
|
||||
for _, e := range entries {
|
||||
if e.Event == BuildEndedEvent && !e.At.Before(since) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
|
||||
return out
|
||||
}
|
||||
|
||||
// EncodeBuildLogEntry is e as JSON at Version; it refuses an entry a reader could not place.
|
||||
func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) {
|
||||
e.V = Version
|
||||
switch {
|
||||
case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent:
|
||||
return nil, fmt.Errorf("delivery: a build log entry is %s or %s, not %q", BuildStartedEvent, BuildEndedEvent, e.Event)
|
||||
case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "":
|
||||
return nil, fmt.Errorf("delivery: a build log entry names its module, fingerprint, repository, commit, node and time")
|
||||
case e.Event == BuildEndedEvent && e.Outcome == "":
|
||||
return nil, fmt.Errorf("delivery: a build's end names its outcome")
|
||||
case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0):
|
||||
return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base or artifact")
|
||||
}
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
// DecodeBuildLogEntry reads one entry.
|
||||
func DecodeBuildLogEntry(data []byte) (BuildLogEntry, error) {
|
||||
var e BuildLogEntry
|
||||
if err := json.Unmarshal(data, &e); err != nil {
|
||||
return BuildLogEntry{}, fmt.Errorf("delivery: a build log entry is not readable: %w", err)
|
||||
}
|
||||
if e.V != Version {
|
||||
return BuildLogEntry{}, fmt.Errorf("delivery: a build log entry of version %d, and this reads %d", e.V, Version)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// BuildDuration is a build's duration, its end less its start (clause 1): false unless both are the same module's
|
||||
// build of the same fingerprint, the start first.
|
||||
func BuildDuration(start, end BuildLogEntry) (time.Duration, bool) {
|
||||
if start.Event != BuildStartedEvent || end.Event != BuildEndedEvent || start.Module != end.Module || start.FP != end.FP ||
|
||||
end.At.Before(start.At) {
|
||||
return 0, false
|
||||
}
|
||||
return end.At.Sub(start.At), true
|
||||
}
|
||||
|
||||
// **Which modules exist and which machines the mesh has, from the bus** (novox/hq ADR 0571 clauses 4, 5; the lead for a6,
|
||||
// 2026-10-29): the tool runner reads the modules from the indexer's state `modules` and the machines from the placement
|
||||
// seat's keys, and no longer asks the controller's `modules` or `nodes`.
|
||||
|
||||
// ModuleNames is every module the indexer holds, sorted, from its state `modules` as a reader holds its keys (key to
|
||||
// value; a deleted key absent). A key that is not a module's is refused, naming it.
|
||||
func ModuleNames(keys map[string][]byte) ([]string, error) {
|
||||
out := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
module, err := ParseModulesKey(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, module)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MachineNames is every machine the placement seat places modules on, sorted, from its state `placement` as a reader
|
||||
// holds its keys: each `placement.<node>`. Keys of the state's other kinds (a proposal's) are passed over.
|
||||
func MachineNames(keys map[string][]byte) []string {
|
||||
out := []string{}
|
||||
for k := range keys {
|
||||
if node, err := ParsePlacementKey(k); err == nil {
|
||||
out = append(out, node)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
+13
-4
@@ -3,6 +3,7 @@ package delivery
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -84,9 +85,6 @@ func judged(from, to Composed) (Judgement, string) {
|
||||
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
|
||||
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
|
||||
}
|
||||
if from.Data != to.Data {
|
||||
return JudgedMovesData, "its data declaration changes"
|
||||
}
|
||||
places := dataPlaces(before, after)
|
||||
held := func(kind string) bool {
|
||||
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
|
||||
@@ -104,9 +102,20 @@ func judged(from, to Composed) (Judgement, string) {
|
||||
if to.Data || from.Data {
|
||||
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
|
||||
}
|
||||
// A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29:
|
||||
// a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for
|
||||
// moving its data). It is said loudly in the words, and never hides an item that moved.
|
||||
if len(moved) > 0 {
|
||||
sort.Strings(moved)
|
||||
return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ")
|
||||
words := "it changes where its data is kept: " + strings.Join(moved, ", ")
|
||||
if from.Data != to.Data {
|
||||
words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data)
|
||||
}
|
||||
return JudgedMovesData, words
|
||||
}
|
||||
if from.Data != to.Data {
|
||||
return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+
|
||||
"(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data)
|
||||
}
|
||||
return JudgedNeither, "it recreates no container and moves no data"
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ func TestJudgeMove(t *testing.T) {
|
||||
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false},
|
||||
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
|
||||
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false},
|
||||
{"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false},
|
||||
{"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false},
|
||||
{"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
|
||||
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false},
|
||||
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false},
|
||||
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false},
|
||||
@@ -61,6 +62,9 @@ func TestJudgeMove(t *testing.T) {
|
||||
if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
|
||||
t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
|
||||
}
|
||||
if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") {
|
||||
t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words)
|
||||
}
|
||||
}
|
||||
if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
|
||||
t.Errorf("the node's own composition: %+v", got)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package delivery
|
||||
|
||||
import "time"
|
||||
|
||||
// **Whether a module is being delivered on a node, read from its want and its have** (novox/hq ADR 0564 clause 13 and
|
||||
// Limits 12, ADR 0540 §7; issue 1952 S7): one derivation, used by the delivery view, which says a module `moving`,
|
||||
// `settled` or `stuck` (the messenger's hold hears those), and by the condition keeper, which holds a module's own
|
||||
// findings while it is delivered there and raises what still stands when the delivery ends. Before, it lived in the view
|
||||
// alone (mesh-delivery-view internal/view motionOf), and a second reader would have judged a delivery its own way.
|
||||
|
||||
// The motion of a module on a node, as the view says it.
|
||||
const (
|
||||
MotionMoving = "moving"
|
||||
MotionSettled = "settled"
|
||||
MotionStuck = "stuck"
|
||||
)
|
||||
|
||||
// The outcomes of a motion that settled or stuck.
|
||||
const (
|
||||
// OutcomeRunning is a settled module: the have reached the want, running and healthy.
|
||||
OutcomeRunning = "running"
|
||||
// OutcomeFailed is a have at the wanted fingerprint whose apply or health check failed.
|
||||
OutcomeFailed = "failed"
|
||||
// OutcomeStopped is a module held, or a want the watchman says stopped moving past its bound.
|
||||
OutcomeStopped = "stopped"
|
||||
)
|
||||
|
||||
// DefaultDeliveryBound is how long a module's delivery holds its own findings when its manifest names no bound (ADR 0564
|
||||
// clause 13 (d)): past it, what is held is raised, saying the delivery did not end.
|
||||
const DefaultDeliveryBound = 15 * time.Minute
|
||||
|
||||
// Motion is where one module's want stands on one node.
|
||||
type Motion struct {
|
||||
// State is MotionMoving, MotionSettled or MotionStuck; empty when there is no want to move to (none, removed, or
|
||||
// naming no fingerprint).
|
||||
State string
|
||||
// Outcome and Words are a settled or stuck motion's: running; failed with the have's words; stopped with the have's
|
||||
// words (held) or the watchman's (stopped moving).
|
||||
Outcome string
|
||||
Words string
|
||||
// Delivering says the module is being delivered there (clause 13 (a)): its want names a fingerprint other than its
|
||||
// have's, or its have's phase is applying.
|
||||
Delivering bool
|
||||
// Failed says the delivery ended failed (Limits 12): the node refused or failed its apply (the have's phase failed,
|
||||
// at whichever fingerprint it kept), or the have at the wanted fingerprint is unhealthy. What a hold kept is raised
|
||||
// at once. Apart from State, which says failed only at the wanted fingerprint, as the view always has.
|
||||
Failed bool
|
||||
}
|
||||
|
||||
// Ended says the delivery ended, failed or settled healthy at the want: what a hold kept is judged now.
|
||||
func (m Motion) Ended() bool { return m.Failed || (m.State == MotionSettled && !m.Delivering) }
|
||||
|
||||
// MotionOf is the motion of a module on a node from its want (wanted: one was read), its have (has: one was read) and
|
||||
// the watchman's words when it says the want stopped moving (stuck: it says so and the want has not moved since).
|
||||
func MotionOf(w Want, wanted bool, h Have, has bool, stuckWords string, stuck bool) Motion {
|
||||
if !wanted || w.Removed || w.FP == "" {
|
||||
return Motion{}
|
||||
}
|
||||
m := Motion{Delivering: !has || h.FP != w.FP || h.Phase == PhaseApplying,
|
||||
Failed: has && (h.Phase == PhaseFailed || (h.FP == w.FP && h.Health == HealthFailed))}
|
||||
applied := has && h.FP == w.FP && (h.Composed == "" || h.Composed == w.Composed)
|
||||
switch {
|
||||
case applied && h.Phase == PhaseRunning && h.Health == HealthOK:
|
||||
m.State, m.Outcome = MotionSettled, OutcomeRunning
|
||||
case has && h.Phase == PhaseHeld:
|
||||
m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, h.Detail
|
||||
case has && h.FP == w.FP && (h.Health == HealthFailed || h.Phase == PhaseFailed):
|
||||
m.State, m.Outcome, m.Words = MotionStuck, OutcomeFailed, h.Detail
|
||||
case stuck:
|
||||
m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, stuckWords
|
||||
default:
|
||||
m.State = MotionMoving
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package delivery
|
||||
|
||||
import "testing"
|
||||
|
||||
// 1952 S7 (ADR 0564 clause 13 (a), (c) and Limits 12; ADR 0540 §7): one derivation of whether a module is being
|
||||
// delivered on a node, read by the view (moving, settled, stuck, as its motionOf said them) and by the condition keeper
|
||||
// (delivering, failed, ended). Wants and haves as the assigner and the node-engine write them (assigner.go Want,
|
||||
// delivery.go Have; agent.go writes a refusal as phase failed at the prior fingerprint).
|
||||
func TestMotionOf(t *testing.T) {
|
||||
want := Want{V: Version, FP: "fp-new", Composed: "sha256:new"}
|
||||
at := func(fp, composed string, phase Phase, health Health, detail string) Have {
|
||||
return Have{V: Version, FP: fp, Composed: composed, Phase: phase, Health: health, Detail: detail}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
w Want
|
||||
wanted bool
|
||||
h Have
|
||||
has bool
|
||||
stuck string
|
||||
state string
|
||||
outcome string
|
||||
delivering bool
|
||||
failed bool
|
||||
ended bool
|
||||
}{
|
||||
{name: "no want", h: at("fp-old", "", PhaseRunning, HealthOK, ""), has: true},
|
||||
{name: "a removed want", w: Want{V: Version, Removed: true}, wanted: true},
|
||||
{name: "a want no node has yet", w: want, wanted: true, state: MotionMoving, delivering: true},
|
||||
{name: "the old fingerprint still runs", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true,
|
||||
state: MotionMoving, delivering: true},
|
||||
{name: "applying the wanted fingerprint", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseApplying, HealthUnknown, ""), has: true,
|
||||
state: MotionMoving, delivering: true},
|
||||
{name: "checking at the want: no longer delivering, not yet settled", w: want, wanted: true,
|
||||
h: at("fp-new", "sha256:new", PhaseChecking, HealthUnknown, ""), has: true, state: MotionMoving},
|
||||
{name: "running healthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthOK, ""), has: true,
|
||||
state: MotionSettled, outcome: OutcomeRunning, ended: true},
|
||||
{name: "running healthy at another composition", w: want, wanted: true, h: at("fp-new", "sha256:other", PhaseRunning, HealthOK, ""), has: true,
|
||||
state: MotionMoving},
|
||||
{name: "unhealthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthFailed, "exit 1"), has: true,
|
||||
state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true},
|
||||
{name: "its apply failed at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseFailed, HealthUnknown, "no image"), has: true,
|
||||
state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true},
|
||||
{name: "refused, the prior fingerprint kept", w: want, wanted: true,
|
||||
h: at("fp-old", "sha256:old", PhaseFailed, HealthOK, "not composed: no grant"), has: true,
|
||||
state: MotionMoving, delivering: true, failed: true, ended: true},
|
||||
{name: "held on the node", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseHeld, HealthOK, "held by a person"), has: true,
|
||||
state: MotionStuck, outcome: OutcomeStopped, delivering: true},
|
||||
{name: "the watchman says it stopped moving", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true,
|
||||
stuck: "ace to apply fp-new", state: MotionStuck, outcome: OutcomeStopped, delivering: true},
|
||||
} {
|
||||
m := MotionOf(c.w, c.wanted, c.h, c.has, c.stuck, c.stuck != "")
|
||||
if m.State != c.state || m.Outcome != c.outcome || m.Delivering != c.delivering || m.Failed != c.failed || m.Ended() != c.ended {
|
||||
t.Errorf("%s: %+v (ended %v), want state %q outcome %q delivering %v failed %v ended %v", c.name, m, m.Ended(),
|
||||
c.state, c.outcome, c.delivering, c.failed, c.ended)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A node's own health report on the bus** (issue 774, the operator's alert table of 2026-10-29): one key per node,
|
||||
// `<node>`, of the node-engine's state `health` (NodeHealthBucket), written by that node's engine alone and only when
|
||||
// what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the
|
||||
// passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner),
|
||||
// network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and
|
||||
// directory-used-as-found (Found). Moved here from mesh-host internal/link (65fbc1e6) unchanged; the engine builds a
|
||||
// report from its own health statement there (NodeHealthOf).
|
||||
|
||||
// NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does.
|
||||
const (
|
||||
NodeHealthState = "health"
|
||||
NodeHealthBucket = "mesh-host_" + NodeHealthState
|
||||
NodeHealthRead = "mesh-host." + NodeHealthState
|
||||
)
|
||||
|
||||
// NodeHealthVersion is the version of NodeHealth's layout.
|
||||
const NodeHealthVersion = 1
|
||||
|
||||
// NodeHealth is one node's health report, as its engine last found it.
|
||||
type NodeHealth struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
// Network is the machine's networking, its parts without their streaks; nil where the engine does not judge it yet.
|
||||
Network *NetworkHealth `json:"network,omitempty"`
|
||||
// Peers are the mesh tunnel's peers on the last look that read them, sorted by key.
|
||||
Peers []PeerSeen `json:"peers,omitempty"`
|
||||
// Units is the machine's service managers: each failed unit no module places.
|
||||
Units *UnitsHealth `json:"units,omitempty"`
|
||||
// Relogin are the accounts whose running session needs a new login for a group it was given.
|
||||
Relogin []NodeAccount `json:"relogin,omitempty"`
|
||||
// Found are the directories used as found.
|
||||
Found []NodeDirectory `json:"found,omitempty"`
|
||||
// Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq
|
||||
// ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root
|
||||
// judge an agent's confinement by, on the machine's own word.
|
||||
Root []RootVerdict `json:"root,omitempty"`
|
||||
// Contract is the engine's statement contract (mesh-host link's NodeKeyContract): a reader tells an engine older
|
||||
// than a judging it reads (RootContract for Root) from one that judged nothing wrong.
|
||||
Contract int `json:"contract,omitempty"`
|
||||
// At is when the engine wrote this, on a change: never a reason to write.
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part.
|
||||
type NetworkHealth struct {
|
||||
State string `json:"state"`
|
||||
Since time.Time `json:"since"`
|
||||
Parts []NetworkPart `json:"parts"`
|
||||
}
|
||||
|
||||
// NetworkPart is one part of a machine's networking, as its engine judged it on its second look.
|
||||
type NetworkPart struct {
|
||||
// Part is resolv-conf, names, tunnel, bus or route.
|
||||
Part string `json:"part"`
|
||||
State string `json:"state"`
|
||||
// Reason is why it is not healthy, in words with no address, path or domain; Said the detail.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Said string `json:"said,omitempty"`
|
||||
// Writer is the program that rewrote the resolver file, when it can be named; Owner the module whose file it is.
|
||||
Writer string `json:"writer,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
// Toward is what the failure points at: "hub", or each resolver's address that failed.
|
||||
Toward []string `json:"toward,omitempty"`
|
||||
// Targets are the mesh machines the failure points at, by node name, as the controller holds a part at the machine
|
||||
// it points at: the hub's machine for "hub", and the machine whose tunnel address is each resolver's. Empty where the
|
||||
// part is the machine's own: it points at nothing, at this machine, or at an address no other mesh machine has (the
|
||||
// resolver a VPN client wrote in). Filled by the node-engine from its tunnel's peers, so the watchman needs no
|
||||
// address of any machine (issue 774).
|
||||
Targets []string `json:"targets,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
Streak int `json:"streak,omitempty"`
|
||||
}
|
||||
|
||||
// UnitsHealth is the machine's service managers (issue 315).
|
||||
type UnitsHealth struct {
|
||||
// State is running, degraded or unknown.
|
||||
State string `json:"state"`
|
||||
// Failed is every unit failed on two looks in a row that no module places.
|
||||
Failed []FailedUnit `json:"failed"`
|
||||
// Unread names a manager that could not be read, with why; Away an account's own manager not running.
|
||||
Unread []string `json:"unread,omitempty"`
|
||||
Away []string `json:"away,omitempty"`
|
||||
}
|
||||
|
||||
// FailedUnit is one failed unit no module places.
|
||||
type FailedUnit struct {
|
||||
Unit string `json:"unit"`
|
||||
// Scope is system, or user: an account's own manager.
|
||||
Scope string `json:"scope"`
|
||||
Load string `json:"load,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Resource string `json:"resource,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// PeerSeen is one peer of the mesh's tunnel: its public key, its allowed addresses, and whether this machine saw a
|
||||
// handshake with it within five minutes.
|
||||
type PeerSeen struct {
|
||||
Key string `json:"key"`
|
||||
Addresses []string `json:"addresses,omitempty"`
|
||||
Seen bool `json:"seen"`
|
||||
}
|
||||
|
||||
// NodeAccount is one account marked relogin needed, with the module whose resource it is and why.
|
||||
type NodeAccount struct {
|
||||
Account string `json:"account"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Resource string `json:"resource,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// RootContract is the statement contract from which an engine judges an account's root (mesh-host link.RootContract).
|
||||
const RootContract = 3
|
||||
|
||||
// RootVerdict is the engine's verdict on one account declared never to become root without a person: healthy (no way
|
||||
// found), unhealthy (its reason starts "can become root without a person"), or unknown while its setuid search has not
|
||||
// completed, with the module and resource declaring it.
|
||||
type RootVerdict struct {
|
||||
Account string `json:"account"`
|
||||
Module string `json:"module"`
|
||||
Resource string `json:"resource"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// NodeDirectory is one directory used as found: the module declaring it, its resource and path, and why.
|
||||
type NodeDirectory struct {
|
||||
Module string `json:"module"`
|
||||
Resource string `json:"resource"`
|
||||
Path string `json:"path"`
|
||||
Reason string `json:"reason"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// EncodeNodeHealth is h as JSON at its version.
|
||||
func EncodeNodeHealth(h NodeHealth) ([]byte, error) {
|
||||
h.V = NodeHealthVersion
|
||||
if h.Node == "" {
|
||||
return nil, errors.New("a node's health report names its node")
|
||||
}
|
||||
return json.Marshal(h)
|
||||
}
|
||||
|
||||
// DecodeNodeHealth reads a report.
|
||||
func DecodeNodeHealth(raw []byte) (NodeHealth, error) {
|
||||
var h NodeHealth
|
||||
if err := json.Unmarshal(raw, &h); err != nil {
|
||||
return NodeHealth{}, fmt.Errorf("a node's health report is not readable: %w", err)
|
||||
}
|
||||
if h.V != NodeHealthVersion {
|
||||
return NodeHealth{}, fmt.Errorf("a node's health report of version %d, and this reads %d", h.V, NodeHealthVersion)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// SameNodeHealth says two reports say the same, whenever each was written.
|
||||
func SameNodeHealth(a, b NodeHealth) bool {
|
||||
a.At, b.At = time.Time{}, time.Time{}
|
||||
ra, errA := json.Marshal(a)
|
||||
rb, errB := json.Marshal(b)
|
||||
return errA == nil && errB == nil && bytes.Equal(ra, rb)
|
||||
}
|
||||
@@ -364,12 +364,20 @@ func (p Placement) Held() []catalogue.Held {
|
||||
return out
|
||||
}
|
||||
|
||||
// KeptApart are the modules never placed on one node, each row naming the record that asks it (novox/hq ADR 0564 clause 6
|
||||
// and Limits 5; issue 1952 S8): no manifest word says it, so the check below holds the table. mesh-watcher hears the
|
||||
// self-check's `checked` from a second machine (ADR 0227 §6), which on the self-check's own node would fall silent with
|
||||
// it.
|
||||
var KeptApart = []struct{ A, B, Record string }{
|
||||
{A: "mesh-watcher", B: "mesh-self-check", Record: "ADR 0227 §6, ADR 0564 clause 6"},
|
||||
}
|
||||
|
||||
// CheckPlacements is the holder's check of a proposal laid over every placement (ADR 0567 §2), over the placements by
|
||||
// node, the modules the indexer holds and the nodes with a record. It returns every refusal, in node order, never only the first: a
|
||||
// placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host
|
||||
// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated (catalogue.Seat's Replicated, ADR 0223), which
|
||||
// is held once per node;
|
||||
// two nodes with one address on the private network; more than one hub.
|
||||
// two nodes with one address on the private network; more than one hub; two modules KeptApart on one node.
|
||||
// A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its
|
||||
// ForeseenPorts and Unbound), which the catalogue's composition check reads.
|
||||
func CheckPlacements(placements map[string]Placement, held func(module string) bool, enrolled func(node string) bool) []string {
|
||||
@@ -411,6 +419,15 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
|
||||
if !enrolled(node) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s is a node with no record", node))
|
||||
}
|
||||
placedHere := map[string]bool{}
|
||||
for _, m := range p.Modules {
|
||||
placedHere[m.Module] = true
|
||||
}
|
||||
for _, apart := range KeptApart {
|
||||
if placedHere[apart.A] && placedHere[apart.B] {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: %s and %s are never placed on one node (%s)", node, apart.A, apart.B, apart.Record))
|
||||
}
|
||||
}
|
||||
hostPorts := map[int]string{}
|
||||
for _, m := range p.Modules {
|
||||
if !held(m.Module) && !ProvidedByMesh(m.Module) { // the mesh provides the private network itself
|
||||
|
||||
@@ -179,6 +179,8 @@ func TestPlacement(t *testing.T) {
|
||||
clash.Modules = append([]PlacedModule{{Module: "ghost", Ports: map[string]int{"80": 18080}}}, g14.Modules...)
|
||||
clash.Modules[2] = PlacedModule{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "ace", Module: "postgres"}}}
|
||||
clash.Pins = map[string]Provider{"shell": {Node: "g14", Module: "bash"}}
|
||||
// ADR 0564 clause 6, Limits 5 (issue 1952 S8): mesh-watcher and mesh-self-check are never placed on one node.
|
||||
clash.Modules = append(clash.Modules, PlacedModule{Module: "mesh-watcher"}, PlacedModule{Module: "mesh-self-check"})
|
||||
twice := novox
|
||||
twice.Node = "stranger"
|
||||
twice.Decided.Address = "10.77.0.2"
|
||||
@@ -186,7 +188,8 @@ func TestPlacement(t *testing.T) {
|
||||
for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080",
|
||||
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is a node with no record",
|
||||
"mesh-store, held once for the mesh, is held on novox and stranger",
|
||||
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} {
|
||||
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it",
|
||||
"g14: mesh-watcher and mesh-self-check are never placed on one node (ADR 0227 §6, ADR 0564 clause 6)"} {
|
||||
found := false
|
||||
for _, line := range r {
|
||||
found = found || strings.Contains(line, want)
|
||||
|
||||
@@ -542,6 +542,31 @@ type Forge struct {
|
||||
// refused, loudly, rather than read with them dropped.
|
||||
RetiredPlacements json.RawMessage `json:"placements,omitempty"`
|
||||
RetiredRead bool `json:"placements-read,omitempty"`
|
||||
// Predefined is the catalogue's predefined.json, exact, at the merge commit of a merged message (novox/hq ADR 0565
|
||||
// §2): what the indexer keeps as its state `predefined`. PredefinedRead says the bridge read the merge and it holds
|
||||
// no predefined.json. Carried on a merged message only; an older bridge sends neither, and the indexer keeps what it
|
||||
// held.
|
||||
Predefined json.RawMessage `json:"predefined,omitempty"`
|
||||
PredefinedRead bool `json:"predefined-read,omitempty"`
|
||||
}
|
||||
|
||||
// checkPredefined refuses the predefined list on anything but a merged message, a list that is not one JSON object, and
|
||||
// a list said both present and absent.
|
||||
func (f Forge) checkPredefined() error {
|
||||
if len(f.Predefined) == 0 && !f.PredefinedRead {
|
||||
return nil
|
||||
}
|
||||
if f.Kind != ForgeMerged {
|
||||
return fmt.Errorf("delivery: only a merged message carries the predefined list, not a %s message", f.Kind)
|
||||
}
|
||||
if len(f.Predefined) > 0 && f.PredefinedRead {
|
||||
return fmt.Errorf("delivery: a merged message carries the predefined list and says the merge holds none (predefined-read)")
|
||||
}
|
||||
var list map[string]json.RawMessage
|
||||
if len(f.Predefined) > 0 && json.Unmarshal(f.Predefined, &list) != nil {
|
||||
return fmt.Errorf("delivery: the predefined list a merged message carries is not one JSON object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f Forge) check() error {
|
||||
@@ -558,6 +583,9 @@ func (f Forge) check() error {
|
||||
return fmt.Errorf("delivery: a forge message carries placement files, which no repository holds since ADR 0567: " +
|
||||
"placement is the seat mesh-placement's state")
|
||||
}
|
||||
if err := f.checkPredefined(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkRepository(f.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+14
-18
@@ -8,8 +8,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 1153/AC1: the stuck key is the stopped key's bucket and key, split at the first dot, refused as a pulse key is.
|
||||
// 1153/AC4: the watchman's module and its three buckets, each that module's state.
|
||||
func TestWatchmanBuckets(t *testing.T) {
|
||||
func TestStuckKey(t *testing.T) {
|
||||
if WatchmanModule != "mesh-delivery-watchman" {
|
||||
t.Fatalf("WatchmanModule = %q", WatchmanModule)
|
||||
}
|
||||
@@ -18,10 +19,6 @@ func TestWatchmanBuckets(t *testing.T) {
|
||||
t.Errorf("the %s bucket is %q, not %s_%s", name, bucket, WatchmanModule, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1153/AC1: the stuck key is the stopped key's bucket and key, split at the first dot, refused as a pulse key is.
|
||||
func TestStuckKey(t *testing.T) {
|
||||
key, err := StuckKey(PresenceBucket, "ace")
|
||||
if err != nil || key != "mesh-delivery-watchman_presence.ace" {
|
||||
t.Fatalf("StuckKey = %q, %v", key, err)
|
||||
@@ -47,7 +44,19 @@ func TestStuckKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1153/AC2: a stuck value round-trips with "v":1, and an unknown or missing version is refused loudly.
|
||||
// 1153/AC3: equal stuck values are the same; one moved is not.
|
||||
func TestStuckValue(t *testing.T) {
|
||||
{
|
||||
a := Stuck{V: Version, Bucket: "b", Key: "k", Waited: "w", Since: time.Unix(100, 0).UTC()}
|
||||
b := a
|
||||
if !SameStuck(a, b) {
|
||||
t.Fatal("equal values are not the same")
|
||||
}
|
||||
b.Moved = true
|
||||
if SameStuck(a, b) {
|
||||
t.Fatal("a moved value is the same as a stuck one")
|
||||
}
|
||||
}
|
||||
s := Stuck{Bucket: PresenceBucket, Key: "ace", Waited: "ace to come back online",
|
||||
Since: time.Date(2026, 10, 18, 20, 0, 0, 0, time.UTC)}
|
||||
raw, err := EncodeStuck(s)
|
||||
@@ -73,16 +82,3 @@ func TestStuckValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1153/AC3: equal stuck values are the same; one moved is not.
|
||||
func TestSameStuck(t *testing.T) {
|
||||
a := Stuck{V: Version, Bucket: "b", Key: "k", Waited: "w", Since: time.Unix(100, 0).UTC()}
|
||||
b := a
|
||||
if !SameStuck(a, b) {
|
||||
t.Fatal("equal values are not the same")
|
||||
}
|
||||
b.Moved = true
|
||||
if SameStuck(a, b) {
|
||||
t.Fatal("a moved value is the same as a stuck one")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@ func hashOf(c byte) string { return "sha256:" + digestOf(c) }
|
||||
// 1943/AC3: a want naming its fingerprint and the digest of the composition it allows round-trips, and so does one
|
||||
// carrying the answer to a failed backup, and from ADR 0547's second milestone one naming the build alone or the yes
|
||||
// a person gave; a digest not of the sha256
|
||||
// form, a removed want naming a digest, an unknown backup answer and a rule past the seventh are refused.
|
||||
// form, a removed want naming a digest, an unknown backup answer and a rule past the eighth are refused.
|
||||
func TestAWantNamesItsComposition(t *testing.T) {
|
||||
for _, w := range []Want{
|
||||
{FP: fp2of('a'), Composed: hashOf('1')},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupAgain},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupSkip, Words: "the operator: go on without a backup"},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleComposed, Words: "no composition of b for ace yet"},
|
||||
// ADR 0570 §4 (issue 1954 D1): held for a provision its new build requires, its words naming the key.
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleProvisions,
|
||||
Words: "the database of keycloak on novox is asked of postgres (novox.keycloak.postgres-database)"},
|
||||
{Removed: true},
|
||||
// ADR 0547 §2, §3 (issue 1126): from the second milestone a want names the build alone, and the yes a person gave.
|
||||
{FP: fp2of('a'), Self: true},
|
||||
@@ -44,7 +47,7 @@ func TestAWantNamesItsComposition(t *testing.T) {
|
||||
{Want{Removed: true, Composed: hashOf('1')}, "removed want names no"},
|
||||
{Want{FP: fp2of('a'), Composed: hashOf('1'), Backup: "later"}, `backup answer "later"`},
|
||||
{Want{Removed: true, Backup: BackupAgain}, "removed want names no"},
|
||||
{Want{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8}, "rule 8"},
|
||||
{Want{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 9}, "rule 9"},
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if _, err := EncodeWant(r.w); err == nil || !strings.Contains(err.Error(), r.why) {
|
||||
@@ -54,8 +57,8 @@ func TestAWantNamesItsComposition(t *testing.T) {
|
||||
if _, err := DecodeWant([]byte(`{"v":1,"fp":"` + fp2of('a') + `"}`)); err == nil || !strings.Contains(err.Error(), "names no composition") {
|
||||
t.Errorf("a want of release 5, naming no composition and not the node's own, was read: %v", err)
|
||||
}
|
||||
if RuleComposed != 7 {
|
||||
t.Errorf("RuleComposed = %d, want the seventh rule", RuleComposed)
|
||||
if RuleComposed != 7 || RuleProvisions != 8 {
|
||||
t.Errorf("RuleComposed = %d and RuleProvisions = %d, want the seventh and the eighth", RuleComposed, RuleProvisions)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Package handacts is the hand-act log's contract (novox/hq ADR 0565 clauses 10 and 11, issue 1953): the layout of an act,
|
||||
// moved unchanged from the controller's (mesh-controller internal/link HandAct) so one reader reads both logs alike; the
|
||||
// names of the seat mesh-hand-acts and of what its holder keeps; and the recorder every module records through, which
|
||||
// asks the seat and, while no holder answers, the controller's verb until the controller hands its lease away, saying
|
||||
// which log took the act.
|
||||
package handacts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The seat, its holder and what it keeps (ADR 0565 clause 10).
|
||||
const (
|
||||
Seat = "mesh-hand-acts"
|
||||
Module = "mesh-hand-acts"
|
||||
// ActsLog is the log of every act, kept 400 days; WarrantsState one key `<asker>.<ask>` per warrant, made once.
|
||||
ActsLog = "acts"
|
||||
WarrantsState = "warrants"
|
||||
// EventRecorded is said on the seat for every act, with the act as its body.
|
||||
EventRecorded = "act-recorded"
|
||||
// ControllerActs is the controller's log as a reader names it, read until its lease ends.
|
||||
ControllerActs = "mesh-controller.hand-acts"
|
||||
)
|
||||
|
||||
// KeptFor is how long an act is kept.
|
||||
const KeptFor = 400 * 24 * time.Hour
|
||||
|
||||
// The verbs an act is recorded under, as the controller's log names them.
|
||||
const (
|
||||
VerbRecord = "hand-act record"
|
||||
VerbDrill = "hand-act drill"
|
||||
VerbWarrant = "warrant"
|
||||
// CauseDrill is a drill's cause, never counted as a repair (S15); CauseOperatorAnswer an act the operator chose.
|
||||
CauseDrill = "drill"
|
||||
CauseOperatorAnswer = "operator-answer"
|
||||
// KindRecordedBuilds is a push that only moved recorded builds: no repair (ADR 0242).
|
||||
KindRecordedBuilds = "recorded-builds"
|
||||
)
|
||||
|
||||
// Act is one entry of the log, the controller's HandAct unchanged.
|
||||
type Act struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
// By is who: the caller the runtime proved.
|
||||
By string `json:"by"`
|
||||
// Verb and Args are the act as given.
|
||||
Verb string `json:"verb"`
|
||||
Args []string `json:"arguments,omitempty"`
|
||||
Why string `json:"why"`
|
||||
// Cause is the condition kind, or a word the person gives; the verb's own name when neither.
|
||||
Cause string `json:"cause"`
|
||||
Condition string `json:"condition,omitempty"`
|
||||
// Kind is what the act was read to be from what it did, never a word a person gives.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Carried []string `json:"carried,omitempty"`
|
||||
// Via, Ask, Proofs and RequestedBy are an act the operator chose on a warrant.
|
||||
Via string `json:"via,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Proofs []string `json:"proofs,omitempty"`
|
||||
RequestedBy string `json:"requested-by,omitempty"`
|
||||
// Outcome is what came of an act recorded after it was done.
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
// Log is which log holds it, set when it is read (LogSeat or LogController); never written.
|
||||
Log string `json:"log,omitempty"`
|
||||
}
|
||||
|
||||
// The logs an act may be held in.
|
||||
const (
|
||||
LogSeat = Seat
|
||||
LogController = "mesh-controller"
|
||||
)
|
||||
|
||||
// Repair says the act counts toward a cause repeated (S15): not a drill, not an operator's answer, not recorded builds.
|
||||
func (a Act) Repair() bool {
|
||||
return a.Cause != CauseDrill && a.Verb != VerbDrill && a.Cause != CauseOperatorAnswer && a.Kind != KindRecordedBuilds
|
||||
}
|
||||
|
||||
// Request is one act a recorder records: what, why, its cause, the condition it addresses, and its verb in the log (a
|
||||
// repair when empty).
|
||||
type Request struct {
|
||||
What string `json:"what"`
|
||||
Why string `json:"why"`
|
||||
Cause string `json:"cause,omitempty"`
|
||||
Condition string `json:"condition,omitempty"`
|
||||
Verb string `json:"verb,omitempty"`
|
||||
}
|
||||
|
||||
// Taken is which log took an act, and what its holder answered.
|
||||
type Taken struct {
|
||||
Log string `json:"log"`
|
||||
Answer json.RawMessage `json:"answer,omitempty"`
|
||||
}
|
||||
|
||||
// Recorder records acts for a module (ADR 0565 clause 11): through the seat mesh-hand-acts, and while the seat has no
|
||||
// holder that answers, through the controller's verb until the controller hands its lease away (the release's order
|
||||
// gives no seat's holder before its callers; 00-plan.md, "Missing" 1).
|
||||
type Recorder struct {
|
||||
// Ask calls a seat verb by its address (`seat:<seat>.<verb>`): stdio.Ask through the runtime.
|
||||
Ask func(key string, body any) (json.RawMessage, error)
|
||||
// LeaseAway says the controller's lease is handed away: then nothing falls back to it. Nil is never handed away.
|
||||
LeaseAway func() bool
|
||||
}
|
||||
|
||||
// NoResponder says err is the bus finding nobody to answer: the seat has no holder now.
|
||||
func NoResponder(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "no responders")
|
||||
}
|
||||
|
||||
// Record records one act.
|
||||
func (r Recorder) Record(q Request) (Taken, error) {
|
||||
if strings.TrimSpace(q.What) == "" || strings.TrimSpace(q.Why) == "" {
|
||||
return Taken{}, errors.New("a hand act says what was done and why: nothing was recorded")
|
||||
}
|
||||
body := map[string]any{"what": q.What, "why": q.Why}
|
||||
for k, v := range map[string]string{"cause": q.Cause, "condition": q.Condition, "verb": q.Verb} {
|
||||
if v != "" {
|
||||
body[k] = v
|
||||
}
|
||||
}
|
||||
controller := map[string]any{"what": q.What, "why": q.Why, "cause": q.Cause}
|
||||
if q.Condition != "" {
|
||||
controller["condition"] = q.Condition
|
||||
}
|
||||
if q.Cause == "" {
|
||||
controller["cause"] = q.Verb
|
||||
}
|
||||
return r.either("hand-act", body, "hand-act", controller)
|
||||
}
|
||||
|
||||
// RecordWarrant records what the asking module did on the operator's warrant for its ask.
|
||||
func (r Recorder) RecordWarrant(asker, ask string) (Taken, error) {
|
||||
body := map[string]any{"asker": asker, "ask": ask}
|
||||
return r.either("warranted", body, "warranted", body)
|
||||
}
|
||||
|
||||
func (r Recorder) either(verb string, body any, controllerVerb string, controllerBody any) (Taken, error) {
|
||||
answer, err := r.Ask("seat:"+Seat+"."+verb, body)
|
||||
if err == nil {
|
||||
return Taken{Log: LogSeat, Answer: answer}, nil
|
||||
}
|
||||
if !NoResponder(err) {
|
||||
return Taken{}, fmt.Errorf("%s.%s refused: %w", Seat, verb, err)
|
||||
}
|
||||
if r.LeaseAway != nil && r.LeaseAway() {
|
||||
return Taken{}, fmt.Errorf("%s has no holder that answers, and the controller handed its lease away: nothing "+
|
||||
"was recorded (%w)", Seat, err)
|
||||
}
|
||||
answer, cerr := r.Ask("seat:mesh-controller."+controllerVerb, controllerBody)
|
||||
if cerr != nil {
|
||||
return Taken{}, fmt.Errorf("%s has no holder that answers (%v), and the controller's %s refused too: %w", Seat, err,
|
||||
controllerVerb, cerr)
|
||||
}
|
||||
return Taken{Log: LogController, Answer: answer}, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package handacts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4): an act reads the controller's entries unchanged, and a recorder
|
||||
// records through the seat, through the controller's verb only while the seat has no holder and the lease is the
|
||||
// controller's, saying which log took it, and refuses to record nowhere.
|
||||
func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) {
|
||||
// The shape of mesh-controller internal/link HandAct, as its bucket keeps an entry (handacts.go at 0fbb5ef3).
|
||||
raw := `{"id":"act-1","at":"2026-10-28T09:00:00Z","by":"person.jochen","verb":"hand-act record","why":"a stuck consumer",` +
|
||||
`"cause":"consumer-lost","condition":"bus.EVENTS.x.consumer-lost","via":"telegram","ask":"a-1","proofs":["P1"],` +
|
||||
`"requested-by":"bus.EVENTS.x.consumer-lost","outcome":"done"}`
|
||||
var a Act
|
||||
if err := json.Unmarshal([]byte(raw), &a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _ := json.Marshal(a)
|
||||
var want, got map[string]any
|
||||
_ = json.Unmarshal([]byte(raw), &want)
|
||||
_ = json.Unmarshal(again, &got)
|
||||
if !reflect.DeepEqual(want, got) || !a.Repair() || (Act{Cause: CauseDrill}).Repair() || (Act{Verb: VerbDrill}).Repair() {
|
||||
t.Errorf("the controller's entry reads back as %s", again)
|
||||
}
|
||||
noResponders := errors.New("nats: no responders available for request")
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
seat error
|
||||
ctrl error
|
||||
away bool
|
||||
log string
|
||||
refused string
|
||||
asked string
|
||||
warrants bool
|
||||
}{
|
||||
{name: "the seat answers", log: LogSeat, asked: "seat:mesh-hand-acts.hand-act"},
|
||||
{name: "no holder yet", seat: noResponders, log: LogController, asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"},
|
||||
{name: "no holder, the lease handed away", seat: noResponders, away: true, refused: "handed its lease away",
|
||||
asked: "seat:mesh-hand-acts.hand-act"},
|
||||
{name: "the seat refuses", seat: errors.New("why is required"), refused: "refused", asked: "seat:mesh-hand-acts.hand-act"},
|
||||
{name: "neither answers", seat: noResponders, ctrl: noResponders, refused: "refused too",
|
||||
asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"},
|
||||
{name: "a warrant without a holder", seat: noResponders, warrants: true, log: LogController,
|
||||
asked: "seat:mesh-hand-acts.warranted,seat:mesh-controller.warranted"},
|
||||
} {
|
||||
var asked []string
|
||||
r := Recorder{LeaseAway: func() bool { return c.away }, Ask: func(key string, body any) (json.RawMessage, error) {
|
||||
asked = append(asked, key)
|
||||
if strings.HasPrefix(key, "seat:mesh-hand-acts.") {
|
||||
return json.RawMessage(`{}`), c.seat
|
||||
}
|
||||
return json.RawMessage(`{}`), c.ctrl
|
||||
}}
|
||||
var taken Taken
|
||||
var err error
|
||||
if c.warrants {
|
||||
taken, err = r.RecordWarrant("mesh-config", "ask-1")
|
||||
} else {
|
||||
taken, err = r.Record(Request{What: "silenced", Why: "known", Cause: "silent", Verb: "conditions silence"})
|
||||
}
|
||||
if (c.refused != "") != (err != nil) || (err != nil && !strings.Contains(err.Error(), c.refused)) || taken.Log != c.log ||
|
||||
strings.Join(asked, ",") != c.asked {
|
||||
t.Errorf("%s: took %q, %v, asked %v", c.name, taken.Log, err, asked)
|
||||
}
|
||||
}
|
||||
if _, err := (Recorder{}).Record(Request{What: "x"}); err == nil {
|
||||
t.Error("an act with no why was recorded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package health
|
||||
|
||||
// **A condition and its events, as the controller says them today** (novox/hq ADR 0564 clause 3, issue 1952): the body
|
||||
// of `condition-raised`, `condition-changed` and `condition-cleared`, ported field for field from mesh-controller
|
||||
// internal/conditions (condition.go, events.go, plain.go at 0fbb5ef3), so the condition keeper says them unchanged under
|
||||
// the seat mesh-conditions and a reader (the router, the grants part, the healers) changes only the seat it hears.
|
||||
//
|
||||
// A condition is a durable fact about something the mesh owns that is wrong: raised when an observer's finding says so,
|
||||
// kept with since-when, evidence and who can resolve it, said on the bus as it changes, and cleared when the finding
|
||||
// goes, never by hand (ADR 0231 §14, ADR 0564 clause 2).
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Severity is how soon the operator is needed: two levels, no more.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
// Urgent needs the operator now.
|
||||
Urgent Severity = "urgent"
|
||||
// Warning needs the operator when they can.
|
||||
Warning Severity = "warning"
|
||||
)
|
||||
|
||||
// The scopes a condition's key starts with: what kind of thing is wrong.
|
||||
const (
|
||||
ScopeMachine = "machine"
|
||||
ScopePlan = "plan"
|
||||
ScopeCall = "call"
|
||||
ScopeBuild = "build"
|
||||
ScopeMerge = "merge"
|
||||
ScopeProvider = "provider"
|
||||
ScopeSeat = "seat"
|
||||
ScopeBus = "bus"
|
||||
ScopeCore = "core"
|
||||
ScopeProbe = "probe"
|
||||
ScopeMesh = "mesh"
|
||||
// ScopeDelivery is a delivery mesh-delivery owns, by its id (novox/hq ADR 0239).
|
||||
ScopeDelivery = "delivery"
|
||||
// ScopeModule is a module on a machine, by `<module>.<machine>` (novox/hq ADR 0240).
|
||||
ScopeModule = "module"
|
||||
)
|
||||
|
||||
// Scopes is every scope, in the order a person reads them.
|
||||
var Scopes = []string{ScopeMachine, ScopePlan, ScopeCall, ScopeBuild, ScopeMerge, ScopeProvider,
|
||||
ScopeSeat, ScopeBus, ScopeCore, ScopeProbe, ScopeMesh, ScopeDelivery, ScopeModule}
|
||||
|
||||
// Who resolves a condition.
|
||||
const (
|
||||
// ResolverSelf clears on observation: the signal returns, the probe passes.
|
||||
ResolverSelf = "self"
|
||||
// ResolverOperator needs a person: a healer's budget spent, or a repair that could only destroy.
|
||||
ResolverOperator = "operator"
|
||||
// ResolverAgent is work handed to an agent.
|
||||
ResolverAgent = "agent"
|
||||
)
|
||||
|
||||
// ResolverHealer is the resolver of a condition a registered healer works on.
|
||||
func ResolverHealer(name string) string { return "healer:" + name }
|
||||
|
||||
// Subject is what the condition is about: its scope, its id within the scope, and the machine it concerns when there is
|
||||
// one.
|
||||
type Subject struct {
|
||||
Scope string `json:"scope"`
|
||||
ID string `json:"id"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
// Also are the other machines it concerns: a consumer's, for a provider failing it.
|
||||
Also []string `json:"also,omitempty"`
|
||||
}
|
||||
|
||||
// Evidence is one observation, as it was said.
|
||||
type Evidence struct {
|
||||
At time.Time `json:"at"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// Tried is one healer's try at a condition, as the condition carries it in `tried`: when, what it did, what came of it,
|
||||
// and which healer (`healer H3`), never a person. The keeper writes it from the healers' attempts (ADR 0564 clause 7).
|
||||
type Tried struct {
|
||||
At time.Time `json:"at"`
|
||||
What string `json:"what"`
|
||||
Outcome string `json:"outcome"`
|
||||
By string `json:"by"`
|
||||
}
|
||||
|
||||
// Silence is a person saying they know: no messages until it ends. The condition stays open.
|
||||
type Silence struct {
|
||||
Until time.Time `json:"until"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// Gap is a stretch in which a fault was cleared, between its clearing and its reopening.
|
||||
type Gap struct {
|
||||
Cleared time.Time `json:"cleared"`
|
||||
Reopened time.Time `json:"reopened"`
|
||||
}
|
||||
|
||||
// Action is one answer the operator may give from a notification: a label, and the seat verb the operator channel calls
|
||||
// with these arguments when it is chosen (novox/hq ADR 0253). Level is how much proof its answer needs (ADR 0234 §8,
|
||||
// ADR 0259).
|
||||
type Action struct {
|
||||
Label string `json:"label"`
|
||||
Verb string `json:"verb"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Arguments map[string]string `json:"arguments,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
}
|
||||
|
||||
// The assurance levels an action's answer needs (novox/hq ADR 0234 §8).
|
||||
const (
|
||||
LevelAcknowledge = "acknowledge"
|
||||
LevelApprove = "approve"
|
||||
)
|
||||
|
||||
// The bounds a condition keeps, as the controller keeps them.
|
||||
const (
|
||||
// KeptEvidence is how many observations a condition keeps, newest first.
|
||||
KeptEvidence = 10
|
||||
// KeptTried is how many tries a condition keeps, newest last.
|
||||
KeptTried = 10
|
||||
// KeptGaps is how many gaps a condition keeps.
|
||||
KeptGaps = 8
|
||||
// MaxSilence is the longest a condition may be silenced at once.
|
||||
MaxSilence = 7 * 24 * time.Hour
|
||||
// ReopenWithin is how soon after it cleared a condition raised again is the same one again, its count increased,
|
||||
// rather than news.
|
||||
ReopenWithin = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Condition is one open condition, as the keeper keeps it and its events carry it: the controller's body, field for
|
||||
// field (ADR 0564 clause 3). The keeper writes Epoch 0 (Limits 10).
|
||||
type Condition struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
Subject Subject `json:"subject"`
|
||||
Severity Severity `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
Headline string `json:"headline"`
|
||||
Explanation string `json:"explanation"`
|
||||
Resolved string `json:"resolved"`
|
||||
Needs string `json:"needs"`
|
||||
Actions []Action `json:"actions"`
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
// Source is the observer's row, probe or event that raised it: `S1`, `D3`, `provisioner.failing`.
|
||||
Source string `json:"source"`
|
||||
Raised time.Time `json:"raised"`
|
||||
LastObserved time.Time `json:"last-observed"`
|
||||
// First and Gaps are the fault's earlier life: a reopening within ReopenWithin is the same fault.
|
||||
First time.Time `json:"first,omitzero"`
|
||||
Gaps []Gap `json:"gaps,omitempty"`
|
||||
Observations int `json:"observations"`
|
||||
Count int `json:"count"`
|
||||
Tried []Tried `json:"tried,omitempty"`
|
||||
Resolver string `json:"resolver"`
|
||||
// Silenced is null when no silence is in force: said, not left out.
|
||||
Silenced *Silence `json:"silenced"`
|
||||
Epoch uint64 `json:"epoch"`
|
||||
// LooksUnread says why Observations, LastObserved and Evidence are not known; the controller's alone.
|
||||
LooksUnread string `json:"looks-unread,omitempty"`
|
||||
}
|
||||
|
||||
// SilencedAt says whether a person's silence is in force at a moment.
|
||||
func (c Condition) SilencedAt(now time.Time) bool {
|
||||
return c.Silenced != nil && now.Before(c.Silenced.Until)
|
||||
}
|
||||
|
||||
// Escalated says a healer tried and its budget is spent: the operator resolves it now.
|
||||
func (c Condition) Escalated() bool { return c.Resolver == ResolverOperator && len(c.Tried) > 0 }
|
||||
|
||||
// Began is when this fault began as the mesh knows it: its first raising, a reopening within ReopenWithin being the
|
||||
// same fault again.
|
||||
func (c Condition) Began() time.Time {
|
||||
if !c.First.IsZero() && c.First.Before(c.Raised) {
|
||||
return c.First
|
||||
}
|
||||
return c.Raised
|
||||
}
|
||||
|
||||
// ShowKeeper is the verb that shows more about a condition the keeper says (ADR 0564 Limits 9); a condition carried from
|
||||
// the controller's bucket keeps the controller's (ShowController) until the lease write.
|
||||
func ShowKeeper(key string) string { return "mesh-conditions.conditions key=" + key }
|
||||
|
||||
// ShowController is the controller's verb that shows more about a condition it raised.
|
||||
func ShowController(key string) string { return "mesh-controller.conditions key=" + key }
|
||||
|
||||
// SilenceAction is the action that stops a condition's messages for a week, as the keeper offers it (ADR 0564 Limits 9:
|
||||
// `mesh-conditions.silence`). An acknowledgement (ADR 0234 §8); its cause marks it as an answer.
|
||||
func SilenceAction(key string) Action {
|
||||
return Action{Label: "Silence for a week", Verb: "mesh-conditions.silence", Level: LevelAcknowledge,
|
||||
Arguments: map[string]string{"key": key, "for": "7d", "why": "", "cause": CauseOperatorAnswer}}
|
||||
}
|
||||
|
||||
// CauseOperatorAnswer is the cause an answer chosen on a notification is recorded with.
|
||||
const CauseOperatorAnswer = "operator-answer"
|
||||
|
||||
// unsafeKey is anything a key may not hold: the bus takes letters, digits and `-_/=` in a key's tokens, and a `*` or `>`
|
||||
// would make one a wildcard.
|
||||
var unsafeKey = regexp.MustCompile(`[^A-Za-z0-9_=/-]`)
|
||||
|
||||
// Key composes a condition's key from its parts, `<scope>.<id>.<token>`, the controller's rule (condition.go Key): each
|
||||
// token made safe for the bus, a character the bus would refuse becoming `_`, an empty token `_`. The id may hold several
|
||||
// tokens joined by dots. A finding and the condition it raises have one key.
|
||||
func Key(scope, id, token string) string {
|
||||
var parts []string
|
||||
for _, p := range append(append([]string{scope}, strings.Split(id, ".")...), token) {
|
||||
p = unsafeKey.ReplaceAllString(strings.TrimSpace(p), "_")
|
||||
if p == "" {
|
||||
p = "_"
|
||||
}
|
||||
parts = append(parts, p)
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// Order sorts conditions as `conditions` says them: urgent before warning, then oldest first, then by key.
|
||||
func Order(list []Condition) {
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
if list[i].Severity != list[j].Severity {
|
||||
return list[i].Severity == Urgent
|
||||
}
|
||||
if !list[i].Raised.Equal(list[j].Raised) {
|
||||
return list[i].Raised.Before(list[j].Raised)
|
||||
}
|
||||
return list[i].Key < list[j].Key
|
||||
})
|
||||
}
|
||||
|
||||
// The events a condition's life emits, under the seat that keeps it (ADR 0564 clause 3: the controller's names).
|
||||
const (
|
||||
// EventRaised: a condition was raised, new or the same fault again within ReopenWithin (Change says which).
|
||||
EventRaised = "condition-raised"
|
||||
// EventChanged: its severity, its resolver, its silence or its words changed; not every observation.
|
||||
EventChanged = "condition-changed"
|
||||
// EventCleared: the finding went; the condition is removed and the transition kept in history.
|
||||
EventCleared = "condition-cleared"
|
||||
)
|
||||
|
||||
// Events is every event a condition's life emits.
|
||||
var Events = []string{EventRaised, EventChanged, EventCleared}
|
||||
|
||||
// What changed, as an event's Change and a history entry say it.
|
||||
const (
|
||||
ChangeRaised = "raised"
|
||||
ChangeReopened = "reopened"
|
||||
ChangeSeverity = "severity"
|
||||
ChangeResolver = "resolver"
|
||||
ChangeSilenced = "silenced"
|
||||
ChangeUnsilenced = "silence-ended"
|
||||
ChangeCleared = "cleared"
|
||||
// ChangeWords is what the operator needs, or the answers offered, said differently (novox/hq ADR 0258).
|
||||
ChangeWords = "words"
|
||||
)
|
||||
|
||||
// Event is the body of every condition event and the shape a history entry keeps: the condition itself at the top level,
|
||||
// with what happened to it beside.
|
||||
type Event struct {
|
||||
Condition
|
||||
// Event is the event's own name, so a body read without its subject still says what it is.
|
||||
Event string `json:"event"`
|
||||
At time.Time `json:"at"`
|
||||
// Change is what happened: raised, reopened, severity, resolver, silenced, silence-ended, words, cleared.
|
||||
Change string `json:"change"`
|
||||
// Was is the value before, for a severity or resolver change.
|
||||
Was string `json:"was,omitempty"`
|
||||
// Why says why it cleared, or why it was silenced.
|
||||
Why string `json:"why,omitempty"`
|
||||
// Cleared is when it cleared, on a clearing.
|
||||
Cleared *time.Time `json:"cleared,omitempty"`
|
||||
// Show is the verb that shows more.
|
||||
Show string `json:"show"`
|
||||
}
|
||||
|
||||
// EventFor is the event a change is said under.
|
||||
func EventFor(change string) string {
|
||||
switch change {
|
||||
case ChangeRaised, ChangeReopened:
|
||||
return EventRaised
|
||||
case ChangeCleared:
|
||||
return EventCleared
|
||||
}
|
||||
return EventChanged
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// ADR 0564 clause 3 (issue 1952 S2): the bodies of condition-raised, condition-changed and condition-cleared read the
|
||||
// events the live controller said unchanged. testdata/condition-events.json holds five transitions of the controller's
|
||||
// condition history (mesh-controller_condition-history, whose entries are the events' bodies, events.go Event), captured
|
||||
// on 2026-10-29 with `mesh-controller.conditions history=true days=1`: a raising, a reopening with gaps, a resolver and a
|
||||
// severity change with tried and was, and a clearing with why. Never written by hand.
|
||||
func TestTheControllersConditionEventsReadUnchanged(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/condition-events.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var captured []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &captured); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changes := map[string]bool{}
|
||||
for _, body := range captured {
|
||||
var e Event
|
||||
if err := json.Unmarshal(body, &e); err != nil {
|
||||
t.Fatalf("%s: %v", body, err)
|
||||
}
|
||||
changes[e.Change] = true
|
||||
again, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want, got map[string]any
|
||||
_ = json.Unmarshal(body, &want)
|
||||
_ = json.Unmarshal(again, &got)
|
||||
if !reflect.DeepEqual(want, got) {
|
||||
t.Errorf("the controller's %s of %s reads back as\n%s\nnot\n%s", e.Change, e.Key, again, body)
|
||||
}
|
||||
if EventFor(e.Change) != e.Event || e.Key != Key(e.Subject.Scope, e.Subject.ID, e.Key[strings.LastIndex(e.Key, ".")+1:]) {
|
||||
t.Errorf("%s: said as %s for a %s, or its key is not its subject's", e.Key, e.Event, e.Change)
|
||||
}
|
||||
}
|
||||
for _, c := range []string{ChangeRaised, ChangeReopened, ChangeResolver, ChangeSeverity, ChangeCleared} {
|
||||
if !changes[c] {
|
||||
t.Errorf("no captured %s", c)
|
||||
}
|
||||
}
|
||||
// The keeper's own body (Limits 9, 10): epoch 0, said and not left out, silenced null, and its show and silence
|
||||
// naming the new seat.
|
||||
c := Condition{Key: "machine.g14.silent", Actions: []Action{SilenceAction("machine.g14.silent")}}
|
||||
b, _ := json.Marshal(Event{Condition: c, Event: EventRaised, Change: ChangeRaised, Show: ShowKeeper(c.Key)})
|
||||
for _, says := range []string{`"epoch":0`, `"silenced":null`, `"show":"mesh-conditions.conditions key=machine.g14.silent"`,
|
||||
`"verb":"mesh-conditions.silence"`} {
|
||||
if !strings.Contains(string(b), says) {
|
||||
t.Errorf("the keeper's body %s does not say %s", b, says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0564 clauses 2, 7, 8, 9 (issue 1952 S3, S4): the layouts between the observers, the healers and the keeper, and the
|
||||
// take-over key the readers follow.
|
||||
func TestTheHealthLayoutsHoldTheKeepersRules(t *testing.T) {
|
||||
at := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
|
||||
// ADR 0571 clause 3: a reading keeps its seven-day peak and is written only when it changes; a partial size is no peak.
|
||||
var prev *Reading
|
||||
for i, step := range []struct {
|
||||
day, size int64
|
||||
partial, wrote bool
|
||||
peak int64
|
||||
}{{20, 100, false, true, 100}, {20, 100, false, false, 100}, {21, 300, false, true, 300}, {22, 50, false, true, 300},
|
||||
{23, 9000, true, true, 300}, {29, 10, false, true, 50}, {30, 10, false, true, 10}} {
|
||||
now := time.Date(2026, 10, int(step.day), 1, 0, 0, 0, time.UTC)
|
||||
size := step.size
|
||||
m := Measured{SizeBytes: &size, MeasuredAt: &now, Precision: "exact"}
|
||||
if step.partial {
|
||||
m.Precision = "partial: stopped"
|
||||
}
|
||||
r, wrote := NextReading(prev, "ace", catalogue.PlacedDataItem{Module: "pg", ID: "data", Path: "/srv/pg"}, m, now)
|
||||
raw, _ := json.Marshal(r)
|
||||
back, err := DecodeReading(raw)
|
||||
if wrote != step.wrote || r.Peak == nil || *r.Peak != step.peak || err != nil || back.ID != "data" {
|
||||
t.Fatalf("reading %d: wrote %v, peak %v, read back %v", i, wrote, r.Peak, err)
|
||||
}
|
||||
prev = &r
|
||||
}
|
||||
// The first reading sets FirstSeen and every one after keeps it, as it keeps a retirement; an older reading reads as before.
|
||||
if first := time.Date(2026, 10, 20, 1, 0, 0, 0, time.UTC); !prev.FirstSeen.Equal(first) {
|
||||
t.Fatalf("first seen %s, not %s", prev.FirstSeen, first)
|
||||
}
|
||||
retired := *prev
|
||||
retired.RetiredAt, retired.RetiredWhy = at, "its module left ace"
|
||||
if r, _ := NextReading(&retired, "ace", retired.PlacedDataItem, retired.Measured, at.Add(time.Hour)); !r.RetiredAt.Equal(at) ||
|
||||
r.RetiredWhy != "its module left ace" || !r.FirstSeen.Equal(prev.FirstSeen) {
|
||||
t.Fatalf("a retired reading moved on as %+v", r)
|
||||
}
|
||||
if old, err := DecodeReading([]byte(`{"v":1,"node":"ace","module":"pg","item":"data"}`)); err != nil || !old.FirstSeen.IsZero() {
|
||||
t.Fatalf("a reading of before read as %+v: %v", old, err)
|
||||
}
|
||||
if raw, _ := json.Marshal(Reading{V: ReadingVersion}); strings.Contains(string(raw), "first_seen") || strings.Contains(string(raw), "retired") {
|
||||
t.Fatalf("zero fields were written: %s", raw)
|
||||
}
|
||||
if ReadingKey("ace", "pg", "data") != "ace.pg.data" || ReadingsBucket != "disk-load_readings" {
|
||||
t.Fatal("the readings' key or bucket moved")
|
||||
}
|
||||
// And restic's last good night per item, keyed as a reading.
|
||||
goodRow, _ := json.Marshal(GoodRow{V: GoodVersion, Node: "ace", Module: "pg", Item: "data", CoveredBy: "/srv/pg", LastGood: at})
|
||||
if g, err := DecodeGoodRow(goodRow); err != nil || !g.LastGood.Equal(at) || GoodKey("ace", "pg", "data") != "ace.pg.data" ||
|
||||
GoodBucket != "restic_good" || GoodRead != "restic.good" {
|
||||
t.Fatalf("a last good night read back as %+v: %v", g, err)
|
||||
}
|
||||
if _, err := DecodeGoodRow([]byte(`{"v":2}`)); err == nil {
|
||||
t.Fatal("a last good night of another version was read")
|
||||
}
|
||||
good := Finding{Key: "provider.keycloak.ace.mesh-mail.failing", Kind: "provider-failing", Token: "failing",
|
||||
Subject: Subject{Scope: ScopeProvider, ID: "keycloak.ace.mesh-mail", Machine: "ace"},
|
||||
Severity: Warning, Summary: "keycloak on ace fails mesh-mail", Source: "S8", Observer: WatchmanModule, At: at}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
edit func(*Finding)
|
||||
fails string
|
||||
}{
|
||||
{name: "a finding keyed by the controller's key rule"},
|
||||
// The key rule is the controller's (condition.go Key): a character the bus refuses becomes `_`, so the finding
|
||||
// and the condition it raises share one key.
|
||||
{name: "an unsafe id", edit: func(f *Finding) {
|
||||
f.Subject.ID, f.Key = "keycloak.ace.mesh mail*", "provider.keycloak.ace.mesh_mail_.failing"
|
||||
}},
|
||||
{name: "a key not its parts'", edit: func(f *Finding) { f.Key = "provider.keycloak.failing" }, fails: "not"},
|
||||
{name: "an unknown scope", edit: func(f *Finding) { f.Subject.Scope = "thing" }, fails: "scope"},
|
||||
{name: "no severity", edit: func(f *Finding) { f.Severity = "" }, fails: "urgent or a warning"},
|
||||
{name: "no observer", edit: func(f *Finding) { f.Observer = "" }, fails: "observer"},
|
||||
{name: "eleven observations", edit: func(f *Finding) { f.Evidence = make([]Evidence, KeptEvidence+1) }, fails: "at most"},
|
||||
} {
|
||||
f := good
|
||||
if c.edit != nil {
|
||||
c.edit(&f)
|
||||
}
|
||||
b, err := EncodeFinding(f)
|
||||
if c.fails != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.fails) {
|
||||
t.Errorf("%s: encoded (%v), want refused saying %q", c.name, err, c.fails)
|
||||
}
|
||||
continue
|
||||
}
|
||||
back, err := DecodeFinding(b)
|
||||
if err != nil || back.Key != f.Key || back.V != FindingVersion || back.Evidence == nil {
|
||||
t.Errorf("%s: read back as %+v, %v", c.name, back, err)
|
||||
}
|
||||
}
|
||||
if FindingsBucket(SelfCheckModule) != "mesh-self-check_findings" || Bucket(HealersModule, AttemptsState) != "mesh-healers_attempts" ||
|
||||
Bucket(KeeperModule, TakeoverState) != "mesh-conditions_takeover" {
|
||||
t.Errorf("the buckets are named %s, %s", FindingsBucket(SelfCheckModule), Bucket(HealersModule, AttemptsState))
|
||||
}
|
||||
|
||||
// An attempt reaches the condition's tried as the controller wrote it: by the healer, its outcome and what it said.
|
||||
if tr := (Attempt{Healer: "H3", Act: "asserted the grants", Outcome: OutcomeEscalated, Said: "budget spent", Begun: at}).Tried(); tr !=
|
||||
(Tried{At: at, What: "asserted the grants", Outcome: "escalated: budget spent", By: "healer H3"}) {
|
||||
t.Errorf("an attempt is tried as %+v", tr)
|
||||
}
|
||||
|
||||
// The take-over key (clause 9): absent follows the controller; the keeper's names who chose; anything else refused.
|
||||
for _, c := range []struct {
|
||||
raw string
|
||||
found bool
|
||||
seat, events string
|
||||
fails bool
|
||||
}{
|
||||
{found: false, seat: "mesh-controller", events: "mesh-controller.doctor-heartbeat"},
|
||||
{raw: `{"v":1,"follow":"controller","by":"the operator"}`, found: true, seat: "mesh-controller", events: "mesh-controller.doctor-heartbeat"},
|
||||
{raw: `{"v":1,"follow":"keeper","by":"the operator","ask":"ask-1"}`, found: true, seat: "mesh-conditions", events: "mesh-self-check.checked"},
|
||||
{raw: `{"v":1,"follow":"keeper"}`, found: true, fails: true},
|
||||
{raw: `{"v":1,"follow":"both","by":"x"}`, found: true, fails: true},
|
||||
{raw: `{"v":2,"follow":"keeper","by":"x"}`, found: true, fails: true},
|
||||
} {
|
||||
got, err := DecodeTakeover([]byte(c.raw), c.found)
|
||||
if c.fails != (err != nil) {
|
||||
t.Errorf("%q: read as %+v, %v", c.raw, got, err)
|
||||
continue
|
||||
}
|
||||
if c.fails {
|
||||
continue
|
||||
}
|
||||
seat, event := got.SelfCheckEvent()
|
||||
if got.Seat() != c.seat || seat+"."+event != c.events {
|
||||
t.Errorf("%q follows %s and %s.%s", c.raw, got.Seat(), seat, event)
|
||||
}
|
||||
if b, err := EncodeTakeover(got); err != nil || !strings.Contains(string(b), `"v":1`) {
|
||||
t.Errorf("%q encodes as %s, %v", c.raw, b, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package health
|
||||
|
||||
// **What the observers write and the keeper reads** (novox/hq ADR 0564 clauses 2, 6, 7, 8 and 9; issue 1952): the
|
||||
// layout of a finding, the self-check's `checked`, the healers' `healer-acted`, an act begun in `attempts` and one the
|
||||
// healers would have taken, and the take-over key the readers follow. The keeper is the one writer of conditions; an
|
||||
// observer writes findings in its own bucket and never another's (clause 2).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The modules holding the four health seats, and the states between them, as `<module>_<state>` buckets.
|
||||
const (
|
||||
// KeeperModule is the condition keeper, holder of mesh-conditions.
|
||||
KeeperModule = "mesh-conditions"
|
||||
// SelfCheckModule holds mesh-self-check; HealersModule mesh-healers; WatchmanModule mesh-watchdogs.
|
||||
SelfCheckModule = "mesh-self-check"
|
||||
HealersModule = "mesh-healers"
|
||||
WatchmanModule = "mesh-delivery-watchman"
|
||||
|
||||
// FindingsState is every observer's own state of findings, one key per finding (clause 2).
|
||||
FindingsState = "findings"
|
||||
// ConditionsState is the keeper's open conditions, one key each, without their looks (clause 3).
|
||||
ConditionsState = "conditions"
|
||||
// TakeoverState is the keeper's take-over, one key TakeoverKey (clause 9).
|
||||
TakeoverState = "takeover"
|
||||
// TakeoverKey is the take-over state's one key.
|
||||
TakeoverKey = "switch"
|
||||
// AttemptsState is the healers' acts begun, each created before it is made (clause 7); WouldState the acts they
|
||||
// would have taken while the lease names a controller (clause 8), which the keeper never reads.
|
||||
AttemptsState = "attempts"
|
||||
WouldState = "would"
|
||||
)
|
||||
|
||||
// Bucket is a module's state as the bus names it, `<module>_<state>`.
|
||||
func Bucket(module, state string) string { return module + "_" + state }
|
||||
|
||||
// FindingsBucket is an observer's bucket of findings.
|
||||
func FindingsBucket(observer string) string { return Bucket(observer, FindingsState) }
|
||||
|
||||
// FindingVersion is the version of a finding's layout.
|
||||
const FindingVersion = 1
|
||||
|
||||
// Finding is one thing an observer sees wrong now, kept as the key Key in its bucket `findings` until it no longer sees
|
||||
// it (clause 2): written only when what it says changes (ADR 0289 §1, §4), deleted when it goes. A key written raises or
|
||||
// changes the condition of the same key; a key deleted clears it. The keeper makes the condition from it: its words,
|
||||
// severity, subject and source unchanged, its evidence kept newest first.
|
||||
type Finding struct {
|
||||
V int `json:"v"`
|
||||
// Key is Key(Subject.Scope, Subject.ID, Token or Kind): the condition's key.
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
// Token is the key's last part when it is not Kind (`silent` for a machine).
|
||||
Token string `json:"token,omitempty"`
|
||||
Subject Subject `json:"subject"`
|
||||
Severity Severity `json:"severity"`
|
||||
// Summary is one line in the mesh's words; Headline, Explanation, Resolved and Needs the plain words of ADR 0253
|
||||
// and Actions the answers a notification offers.
|
||||
Summary string `json:"summary"`
|
||||
Headline string `json:"headline"`
|
||||
Explanation string `json:"explanation"`
|
||||
Resolved string `json:"resolved"`
|
||||
Needs string `json:"needs,omitempty"`
|
||||
Actions []Action `json:"actions,omitempty"`
|
||||
// Evidence is what the observer saw, newest first, at most KeptEvidence. Detail goes here, never in Summary.
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
// Source is the row, probe or event that saw it: `S1`, `D6`, `apply-failing`.
|
||||
Source string `json:"source"`
|
||||
// Resolver is who resolves it, ResolverSelf when empty.
|
||||
Resolver string `json:"resolver,omitempty"`
|
||||
// Observer is the module that wrote it, and Run what of its own saw it last (a self-check run's id, empty for a
|
||||
// watch): the condition's looks are its finding's and its observer's last run (clause 2).
|
||||
Observer string `json:"observer"`
|
||||
Run string `json:"run,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// FindingKey is the key of a finding about scope and id of kind (the token): the condition's key rule.
|
||||
func FindingKey(scope, id, token string) string { return Key(scope, id, token) }
|
||||
|
||||
func (f Finding) token() string {
|
||||
if f.Token != "" {
|
||||
return f.Token
|
||||
}
|
||||
return f.Kind
|
||||
}
|
||||
|
||||
// Check refuses a finding the keeper could not make a condition of, naming why.
|
||||
func (f Finding) Check() error {
|
||||
known := false
|
||||
for _, s := range Scopes {
|
||||
known = known || s == f.Subject.Scope
|
||||
}
|
||||
switch {
|
||||
case !known:
|
||||
return fmt.Errorf("health: a finding's scope is one of %s, not %q", strings.Join(Scopes, ", "), f.Subject.Scope)
|
||||
case strings.TrimSpace(f.Subject.ID) == "":
|
||||
return fmt.Errorf("health: a %s finding names what it is about", f.Subject.Scope)
|
||||
case strings.TrimSpace(f.Kind) == "":
|
||||
return fmt.Errorf("health: the finding %s has no kind", f.Key)
|
||||
case f.Key != Key(f.Subject.Scope, f.Subject.ID, f.token()):
|
||||
return fmt.Errorf("health: the finding's key %q is not %q, its scope, id and kind", f.Key,
|
||||
Key(f.Subject.Scope, f.Subject.ID, f.token()))
|
||||
case f.Severity != Urgent && f.Severity != Warning:
|
||||
return fmt.Errorf("health: the finding %s is urgent or a warning, not %q", f.Key, f.Severity)
|
||||
case strings.TrimSpace(f.Summary) == "":
|
||||
return fmt.Errorf("health: the finding %s says nothing", f.Key)
|
||||
case strings.TrimSpace(f.Source) == "":
|
||||
return fmt.Errorf("health: the finding %s does not say what saw it", f.Key)
|
||||
case strings.TrimSpace(f.Observer) == "":
|
||||
return fmt.Errorf("health: the finding %s does not name its observer", f.Key)
|
||||
case len(f.Evidence) > KeptEvidence:
|
||||
return fmt.Errorf("health: the finding %s keeps %d observations, at most %d", f.Key, len(f.Evidence), KeptEvidence)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeFinding is f as JSON at FindingVersion; it refuses what Check refuses.
|
||||
func EncodeFinding(f Finding) ([]byte, error) {
|
||||
f.V = FindingVersion
|
||||
if f.Evidence == nil {
|
||||
f.Evidence = []Evidence{}
|
||||
}
|
||||
if err := f.Check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(f)
|
||||
}
|
||||
|
||||
// DecodeFinding reads a finding's value; it refuses another version and what Check refuses.
|
||||
func DecodeFinding(data []byte) (Finding, error) {
|
||||
var f Finding
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return Finding{}, fmt.Errorf("health: a finding is not readable: %w", err)
|
||||
}
|
||||
if f.V != FindingVersion {
|
||||
return Finding{}, fmt.Errorf("health: a finding of version %d, and this reads %d", f.V, FindingVersion)
|
||||
}
|
||||
if err := f.Check(); err != nil {
|
||||
return Finding{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// EventChecked is the self-check's event, ending every run (clause 6), with the body of the controller's
|
||||
// `doctor-heartbeat`, which mesh-watcher hears in its place.
|
||||
const EventChecked = "checked"
|
||||
|
||||
// The verdicts of a probe in a run.
|
||||
const (
|
||||
VerdictPassed = "passed"
|
||||
VerdictFailed = "failed"
|
||||
VerdictFailedToRun = "failed-to-run"
|
||||
VerdictDeferred = "deferred"
|
||||
)
|
||||
|
||||
// ProbeVerdict is one probe's outcome in a run (mesh-controller doctor.go probeVerdict).
|
||||
type ProbeVerdict struct {
|
||||
ID string `json:"id"`
|
||||
Verdict string `json:"verdict"`
|
||||
Found []string `json:"found,omitempty"`
|
||||
// Unconfirmed are findings one look can be wrong about, seen by this run and not the one before.
|
||||
Unconfirmed []string `json:"unconfirmed,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
// FoundCount and More are an overview's, never a checked event's.
|
||||
FoundCount int `json:"found-count,omitempty"`
|
||||
More string `json:"more,omitempty"`
|
||||
}
|
||||
|
||||
// CheckedCounts are a run's verdicts, counted.
|
||||
type CheckedCounts struct {
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
FailedToRun int `json:"failed-to-run"`
|
||||
Deferred int `json:"deferred"`
|
||||
}
|
||||
|
||||
// Checked is one run of the self-check and the body of `checked`, the controller's doctorRun (doctor.go:230) unchanged.
|
||||
type Checked struct {
|
||||
Run string `json:"run"`
|
||||
At time.Time `json:"at"`
|
||||
Started time.Time `json:"started"`
|
||||
Took string `json:"took"`
|
||||
IntervalSeconds int `json:"interval-seconds"`
|
||||
Counts CheckedCounts `json:"counts"`
|
||||
Probes []ProbeVerdict `json:"probes"`
|
||||
// Controller is the machine that ran it (the name kept for mesh-watcher, which reads it), and Why what started it.
|
||||
Controller string `json:"controller"`
|
||||
Why string `json:"why"`
|
||||
Unsaid int `json:"unsaid,omitempty"`
|
||||
}
|
||||
|
||||
// EventHealerActed is the healers' event, one per act (clause 7).
|
||||
const EventHealerActed = "healer-acted"
|
||||
|
||||
// The outcomes of an act.
|
||||
const (
|
||||
// OutcomeActing is an act begun and not yet finished: counted against the budget and the brake from its begin.
|
||||
OutcomeActing = "acting"
|
||||
// OutcomeActed is an act that did what it does; whether it repaired anything is the condition's clearing to say.
|
||||
OutcomeActed = "acted"
|
||||
// OutcomeFailed is an act that could not be done.
|
||||
OutcomeFailed = "failed"
|
||||
// OutcomeEscalated is a budget spent, said: the condition handed to the operator.
|
||||
OutcomeEscalated = "escalated"
|
||||
)
|
||||
|
||||
// HealerActed is the body of `healer-acted`, the controller's (healers.go:345) unchanged; mesh-healers leaves Epoch out
|
||||
// (ADR 0564 Limits 10).
|
||||
type HealerActed struct {
|
||||
Event string `json:"event"`
|
||||
At time.Time `json:"at"`
|
||||
Healer string `json:"healer"`
|
||||
By string `json:"by"`
|
||||
Condition string `json:"condition"`
|
||||
Kind string `json:"kind"`
|
||||
Act string `json:"act"`
|
||||
Outcome string `json:"outcome"`
|
||||
Said string `json:"said"`
|
||||
Budget string `json:"budget"`
|
||||
Epoch uint64 `json:"epoch,omitempty"`
|
||||
Show string `json:"show"`
|
||||
}
|
||||
|
||||
// Attempt is one act of a healer as kept in the healers' state `attempts` (clause 7): created before it is made, so a
|
||||
// holder that dies mid-act has spent it and a second instance cannot begin the same act, and finished after. The key is
|
||||
// the healers' own; the keeper reads every value and writes the condition named its `tried`, resolver and escalation.
|
||||
type Attempt struct {
|
||||
Healer string `json:"healer"`
|
||||
Condition string `json:"condition"`
|
||||
Kind string `json:"kind"`
|
||||
// BudgetKey is what the budget counts by: the condition's key unless the healer says another.
|
||||
BudgetKey string `json:"budget-key"`
|
||||
Act string `json:"act"`
|
||||
Outcome string `json:"outcome"`
|
||||
Said string `json:"said,omitempty"`
|
||||
Begun time.Time `json:"begun"`
|
||||
Finished *time.Time `json:"finished,omitempty"`
|
||||
}
|
||||
|
||||
// Tried is the attempt as the condition carries it in `tried`.
|
||||
func (a Attempt) Tried() Tried {
|
||||
outcome := a.Outcome
|
||||
if a.Said != "" {
|
||||
outcome += ": " + a.Said
|
||||
}
|
||||
return Tried{At: a.Begun, What: a.Act, Outcome: outcome, By: "healer " + a.Healer}
|
||||
}
|
||||
|
||||
// Would is an act mesh-healers would have taken while the controller's lease names a controller, kept in `would`: it
|
||||
// counts for no budget, settle or brake and reaches no condition (clause 8).
|
||||
type Would struct {
|
||||
Healer string `json:"healer"`
|
||||
Condition string `json:"condition"`
|
||||
Kind string `json:"kind"`
|
||||
Act string `json:"act"`
|
||||
At time.Time `json:"at"`
|
||||
// Why says why it was not taken: the lease names a controller, which heals.
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// Which source the readers of conditions follow (clause 9).
|
||||
const (
|
||||
FollowController = "controller"
|
||||
FollowKeeper = "keeper"
|
||||
)
|
||||
|
||||
// TakeoverVersion is the version of the take-over key's value.
|
||||
const TakeoverVersion = 1
|
||||
|
||||
// Takeover is the value of the keeper's key `takeover.switch` (clause 9): which source the router, the grants part and
|
||||
// mesh-watcher hear, the controller's condition events and `doctor-heartbeat`, or the keeper's events and the
|
||||
// self-check's `checked`. Written by the keeper alone, on the operator's warrant for a take-over and by `hand-back`. An
|
||||
// absent key is FollowController.
|
||||
type Takeover struct {
|
||||
V int `json:"v"`
|
||||
Follow string `json:"follow"`
|
||||
At time.Time `json:"at"`
|
||||
// By is who chose (the operator, as the warrant proves), and Why why; Ask the approve ask it was chosen on.
|
||||
By string `json:"by"`
|
||||
Why string `json:"why,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
}
|
||||
|
||||
// EncodeTakeover is t as JSON at TakeoverVersion; it refuses a source the readers could not follow.
|
||||
func EncodeTakeover(t Takeover) ([]byte, error) {
|
||||
t.V = TakeoverVersion
|
||||
if err := t.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(t)
|
||||
}
|
||||
|
||||
// DecodeTakeover reads the key as a getter answered it: an absent key follows the controller.
|
||||
func DecodeTakeover(data []byte, found bool) (Takeover, error) {
|
||||
if !found || len(data) == 0 {
|
||||
return Takeover{V: TakeoverVersion, Follow: FollowController}, nil
|
||||
}
|
||||
var t Takeover
|
||||
if err := json.Unmarshal(data, &t); err != nil {
|
||||
return Takeover{}, fmt.Errorf("health: the take-over key is not readable: %w", err)
|
||||
}
|
||||
if t.V != TakeoverVersion {
|
||||
return Takeover{}, fmt.Errorf("health: the take-over key is of version %d, and this reads %d", t.V, TakeoverVersion)
|
||||
}
|
||||
if err := t.check(); err != nil {
|
||||
return Takeover{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t Takeover) check() error {
|
||||
if t.Follow != FollowController && t.Follow != FollowKeeper {
|
||||
return fmt.Errorf("health: the take-over follows %q, not %s or %s", t.Follow, FollowController, FollowKeeper)
|
||||
}
|
||||
if t.Follow == FollowKeeper && strings.TrimSpace(t.By) == "" {
|
||||
return fmt.Errorf("health: a take-over by the keeper names who chose it")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seat is the seat a reader hears condition events on while it follows t: mesh-controller or mesh-conditions.
|
||||
func (t Takeover) Seat() string {
|
||||
if t.Follow == FollowKeeper {
|
||||
return "mesh-conditions"
|
||||
}
|
||||
return "mesh-controller"
|
||||
}
|
||||
|
||||
// SelfCheckEvent is the seat and event a reader of the self-check's run hears while it follows t: the controller's
|
||||
// `doctor-heartbeat`, or mesh-self-check's `checked`.
|
||||
func (t Takeover) SelfCheckEvent() (seat, event string) {
|
||||
if t.Follow == FollowKeeper {
|
||||
return "mesh-self-check", EventChecked
|
||||
}
|
||||
return "mesh-controller", "doctor-heartbeat"
|
||||
}
|
||||
+26
-29
@@ -52,15 +52,6 @@ func TestAWaitIsReadWithWhatItNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerWithoutStateIsReadFromHealthy(t *testing.T) {
|
||||
for raw, want := range map[string]string{`{"healthy":true,"why":"ok"}`: StateHealthy, `{"healthy":false,"why":"down"}`: StateUnhealthy} {
|
||||
got, err := Read([]byte(raw))
|
||||
if err != nil || got.State != want {
|
||||
t.Fatalf("%s read as %+v, %v", raw, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWaitThatCannotBeReadIsUnhealthyAndSaysWhy(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
w []Wait
|
||||
@@ -88,13 +79,6 @@ func TestAWaitThatCannotBeReadIsUnhealthyAndSaysWhy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownStateIsUnhealthy(t *testing.T) {
|
||||
got, err := Read([]byte(`{"state":"fine","healthy":true}`))
|
||||
if err != nil || got.State != StateUnhealthy || got.Healthy {
|
||||
t.Fatalf("read as %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitsBesideAFaultAreSaidAndExcuseNothing(t *testing.T) {
|
||||
got := roundTrip(t, Unhealthy("the share is unreachable", password, Wait{Part: "p", What: "w"}))
|
||||
if got.State != StateUnhealthy || len(got.Waits) != 1 {
|
||||
@@ -111,12 +95,6 @@ func TestWaitingWithNothingIsUnhealthy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotAnAnswerIsAnError(t *testing.T) {
|
||||
if _, err := Read([]byte(`"yes"`)); err == nil {
|
||||
t.Fatal("a string read as an answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryFormOfPathOrAddressIsRefused(t *testing.T) {
|
||||
for _, words := range []string{
|
||||
"the source (/mnt/games)", `the source "/mnt/games"`, "the folder ~/games", "the folder ./games",
|
||||
@@ -136,15 +114,34 @@ func TestEveryFormOfPathOrAddressIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthyStateWithHealthyFalseIsUnhealthy(t *testing.T) {
|
||||
got, err := Read([]byte(`{"state":"healthy","healthy":false,"why":"x"}`))
|
||||
if err != nil || got.State != StateUnhealthy || !strings.Contains(got.Why, "contradict") {
|
||||
t.Fatalf("read as %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingChecksItsWaits(t *testing.T) {
|
||||
if a := Waiting("it waits", Wait{Part: "p", What: "w"}); a.State != StateUnhealthy || !strings.Contains(a.Why, "neither") {
|
||||
t.Fatalf("Waiting with a malformed wait is %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A raw answer is read by what it says: without state from healthy; an unknown state, or healthy with healthy false, as
|
||||
// unhealthy; not an answer at all as an error.
|
||||
func TestARawAnswerIsReadByWhatItSays(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
raw, state, says string
|
||||
fails bool
|
||||
}{
|
||||
{raw: `{"healthy":true,"why":"ok"}`, state: StateHealthy},
|
||||
{raw: `{"healthy":false,"why":"down"}`, state: StateUnhealthy},
|
||||
{raw: `{"state":"fine","healthy":true}`, state: StateUnhealthy},
|
||||
{raw: `{"state":"healthy","healthy":false,"why":"x"}`, state: StateUnhealthy, says: "contradict"},
|
||||
{raw: `"yes"`, fails: true},
|
||||
} {
|
||||
got, err := Read([]byte(c.raw))
|
||||
if c.fails {
|
||||
if err == nil {
|
||||
t.Errorf("%s read as an answer", c.raw)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || got.State != c.state || (c.state == StateUnhealthy && got.Healthy) || !strings.Contains(got.Why, c.says) {
|
||||
t.Errorf("%s read as %+v, %v", c.raw, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// **Disk usage readings and peaks per data item** (novox/hq ADR 0571 clause 3, ADR 0233 §21, §27, §34): disk-load, on every
|
||||
// node, measures each data item the modules of its node declare (catalogue.PlacedDataItem, from compose/frombus.DataItems)
|
||||
// and keeps the newest reading and the item's largest size in seven days as its own state `readings`, one key
|
||||
// `<node>.<module>.<item>`, written only when it changes. The node-backup holder, mounts, nfs-server and the self-check's
|
||||
// probes read it there; the controller keeps none of it. Moved here from mesh-catalog modules/disk-load (b4c237d2).
|
||||
const (
|
||||
DiskLoadModule = "disk-load"
|
||||
ReadingsState = "readings"
|
||||
ReadingsBucket = DiskLoadModule + "_" + ReadingsState
|
||||
ReadingsRead = DiskLoadModule + "." + ReadingsState
|
||||
)
|
||||
|
||||
// ReadingVersion is the version of a reading's layout.
|
||||
const ReadingVersion = 1
|
||||
|
||||
// PeakWindow is how far back the peak looks (ADR 0233 §34).
|
||||
const PeakWindow = 7 * 24 * time.Hour
|
||||
|
||||
// Redundancy is the redundant storage an item is on, as read: zfs, md or btrfs, its pool, array or filesystem, and its
|
||||
// health (nil when it could not be read).
|
||||
type Redundancy struct {
|
||||
Kind string `json:"kind"`
|
||||
Where string `json:"where"`
|
||||
Healthy *bool `json:"healthy"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// Measured is what one measurement found. Precision says what the size is: "exact", "dataset <name>: its whole size",
|
||||
// "partial: ..." (a lower bound, never compared), or "no size: ...".
|
||||
type Measured struct {
|
||||
SizeBytes *int64 `json:"size_bytes,omitempty"`
|
||||
LastWrite *time.Time `json:"last_write,omitempty"`
|
||||
MeasuredAt *time.Time `json:"measured_at,omitempty"`
|
||||
Precision string `json:"precision,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Redundancy *Redundancy `json:"redundancy,omitempty"`
|
||||
}
|
||||
|
||||
// Partial says the size is only a lower bound, which a peak is never made of.
|
||||
func (m Measured) Partial() bool { return strings.HasPrefix(m.Precision, "partial") }
|
||||
|
||||
// DaySize is the largest size measured on one UTC day.
|
||||
type DaySize struct {
|
||||
Day string `json:"day"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// Reading is the value of one key: the item as placed, what was measured, and the largest size in the seven days up to
|
||||
// it (Peak, measured on PeakAt), read from the day maxima Days.
|
||||
type Reading struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
catalogue.PlacedDataItem
|
||||
Measured
|
||||
Peak *int64 `json:"peak_bytes,omitempty"`
|
||||
PeakAt string `json:"peak_day,omitempty"`
|
||||
Days []DaySize `json:"days,omitempty"`
|
||||
// FirstSeen is when the item was first measured on this node, kept by every reading after; RetiredAt and RetiredWhy
|
||||
// say its module left the node, written by whoever retires it and carried on unchanged. Each is left out while zero,
|
||||
// so a reading written before reads as it did.
|
||||
FirstSeen time.Time `json:"first_seen,omitzero"`
|
||||
RetiredAt time.Time `json:"retired_at,omitzero"`
|
||||
RetiredWhy string `json:"retired_why,omitempty"`
|
||||
}
|
||||
|
||||
// ReadingKey is the key of an item on a node: `<node>.<module>.<item>`.
|
||||
func ReadingKey(node, module, item string) string { return node + "." + module + "." + item }
|
||||
|
||||
// DecodeReading reads a key's value; it refuses another version.
|
||||
func DecodeReading(raw []byte) (Reading, error) {
|
||||
var r Reading
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return Reading{}, fmt.Errorf("health: a reading is not readable: %w", err)
|
||||
}
|
||||
if r.V != ReadingVersion {
|
||||
return Reading{}, fmt.Errorf("health: a reading of version %d, and this reads %d", r.V, ReadingVersion)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// NextReading is the reading after a measurement, and whether it differs from the one before in anything but the time it
|
||||
// was taken: a reading that did not change is not written. A measurement with no size (an error, a shallow item) carries
|
||||
// the last size's days on, so the peak outlives one failed measurement.
|
||||
func NextReading(prev *Reading, node string, it catalogue.PlacedDataItem, m Measured, now time.Time) (Reading, bool) {
|
||||
r := Reading{V: ReadingVersion, Node: node, PlacedDataItem: it, Measured: m, FirstSeen: now.UTC()}
|
||||
var days []DaySize
|
||||
if prev != nil {
|
||||
days = prev.Days
|
||||
r.RetiredAt, r.RetiredWhy = prev.RetiredAt, prev.RetiredWhy
|
||||
if !prev.FirstSeen.IsZero() {
|
||||
r.FirstSeen = prev.FirstSeen
|
||||
}
|
||||
}
|
||||
today := now.UTC().Format("2006-01-02")
|
||||
cut := now.UTC().Add(-PeakWindow).Format("2006-01-02")
|
||||
var kept []DaySize
|
||||
todays := int64(-1)
|
||||
for _, d := range days {
|
||||
switch {
|
||||
case d.Day == today:
|
||||
todays = d.Bytes
|
||||
case d.Day >= cut:
|
||||
kept = append(kept, d)
|
||||
}
|
||||
}
|
||||
if m.SizeBytes != nil && !m.Partial() && *m.SizeBytes > todays {
|
||||
todays = *m.SizeBytes
|
||||
}
|
||||
if todays >= 0 {
|
||||
kept = append(kept, DaySize{Day: today, Bytes: todays})
|
||||
}
|
||||
r.Days = kept
|
||||
for _, d := range kept {
|
||||
if r.Peak == nil || d.Bytes >= *r.Peak {
|
||||
b := d.Bytes
|
||||
r.Peak, r.PeakAt = &b, d.Day
|
||||
}
|
||||
}
|
||||
if prev == nil {
|
||||
return r, true
|
||||
}
|
||||
a, b := *prev, r
|
||||
a.MeasuredAt, b.MeasuredAt = nil, nil
|
||||
return r, !reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
// **Each item's last good night** (novox/hq ADR 0571 clause 3, ADR 0564 §6; the lead, 2026-10-29): the node-backup holder
|
||||
// restic keeps, from its tools bundle through the runtime on the night's file event (its night's process runs as the
|
||||
// operator's account and holds no bus account, ADR 0436 §1-2), its own state `good`, one key `<node>.<module>.<item>` for every item a module
|
||||
// declares that a backup path covers, written when it changes: the item, the path whose snapshot keeps it and the newest
|
||||
// good night of its module (zero before the first). The self-check's probe of a stale backup reads it there. Moved here
|
||||
// from mesh-catalog modules/restic (e5ef3bed).
|
||||
const (
|
||||
ResticModule = "restic"
|
||||
GoodState = "good"
|
||||
GoodBucket = ResticModule + "_" + GoodState
|
||||
GoodRead = ResticModule + "." + GoodState
|
||||
GoodVersion = 1
|
||||
)
|
||||
|
||||
// GoodRow is one key of restic's `good`.
|
||||
type GoodRow struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Item string `json:"item"`
|
||||
CoveredBy string `json:"covered_by"`
|
||||
LastGood time.Time `json:"last_good,omitzero"`
|
||||
}
|
||||
|
||||
// GoodKey is a row's key: `<node>.<module>.<item>`, as a reading's.
|
||||
func GoodKey(node, module, item string) string { return ReadingKey(node, module, item) }
|
||||
|
||||
// DecodeGoodRow reads a row; it refuses another version.
|
||||
func DecodeGoodRow(raw []byte) (GoodRow, error) {
|
||||
var g GoodRow
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
return GoodRow{}, fmt.Errorf("health: a last good night is not readable: %w", err)
|
||||
}
|
||||
if g.V != GoodVersion {
|
||||
return GoodRow{}, fmt.Errorf("health: a last good night of version %d, and this reads %d", g.V, GoodVersion)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
[
|
||||
{
|
||||
"actions": null,
|
||||
"at": "2026-10-29T08:41:18.10610281Z",
|
||||
"change": "raised",
|
||||
"count": 1,
|
||||
"epoch": 395230,
|
||||
"event": "condition-raised",
|
||||
"evidence": [
|
||||
{
|
||||
"at": "2026-10-29T08:41:18.10610281Z",
|
||||
"said": "silent for 2m0s"
|
||||
}
|
||||
],
|
||||
"explanation": "Nothing for you to do. The mesh has not heard from g14 for several minutes. A machine asleep or away is normal.",
|
||||
"headline": "g14 is not answering",
|
||||
"key": "machine.g14.silent",
|
||||
"kind": "silent",
|
||||
"last-observed": "2026-10-29T08:41:18.10610281Z",
|
||||
"needs": "",
|
||||
"observations": 1,
|
||||
"raised": "2026-10-29T08:41:18.10610281Z",
|
||||
"resolved": "g14 answers again",
|
||||
"resolver": "self",
|
||||
"severity": "warning",
|
||||
"show": "mesh-controller.conditions key=machine.g14.silent",
|
||||
"silenced": null,
|
||||
"source": "S1",
|
||||
"subject": {
|
||||
"id": "g14",
|
||||
"machine": "g14",
|
||||
"scope": "machine"
|
||||
},
|
||||
"summary": "g14 has not been heard from since 2026-10-29 08:38 UTC (bound 3m0s)"
|
||||
},
|
||||
{
|
||||
"actions": null,
|
||||
"at": "2026-10-28T20:59:53.719121257Z",
|
||||
"change": "reopened",
|
||||
"count": 2,
|
||||
"epoch": 387175,
|
||||
"event": "condition-raised",
|
||||
"evidence": [
|
||||
{
|
||||
"at": "2026-10-28T20:59:53.719121257Z",
|
||||
"said": "70 pending ([70 on EVENTS]), last taken 2026-10-28T20:57:25Z"
|
||||
}
|
||||
],
|
||||
"explanation": "Needs you: restart the controller if this stays, from the mesh MCP server; this notification cannot do it. The controller, which coordinates the mesh, has taken no messages for minutes while some wait. Changes and repairs do not happen until it recovers.",
|
||||
"first": "2026-10-28T20:54:53.510073517Z",
|
||||
"gaps": [
|
||||
{
|
||||
"cleared": "2026-10-28T20:56:52.499154879Z",
|
||||
"reopened": "2026-10-28T20:59:53.719121257Z"
|
||||
}
|
||||
],
|
||||
"headline": "The controller stopped listening",
|
||||
"key": "core.controller.deaf",
|
||||
"kind": "controller-deaf",
|
||||
"last-observed": "2026-10-28T20:59:53.719121257Z",
|
||||
"needs": "restart the controller if this stays, from the mesh MCP server; this notification cannot do it.",
|
||||
"observations": 1,
|
||||
"raised": "2026-10-28T20:59:53.719121257Z",
|
||||
"resolved": "The controller listens again",
|
||||
"resolver": "self",
|
||||
"severity": "urgent",
|
||||
"show": "mesh-controller.conditions key=core.controller.deaf",
|
||||
"silenced": null,
|
||||
"source": "S4",
|
||||
"subject": {
|
||||
"id": "controller",
|
||||
"machine": "novox",
|
||||
"scope": "core"
|
||||
},
|
||||
"summary": "the controller's event loop has taken nothing for 2m0s while its consumers hold 70 message(s): reports, builds and merges are not being acted on"
|
||||
},
|
||||
{
|
||||
"actions": null,
|
||||
"at": "2026-10-29T18:31:48.308721256Z",
|
||||
"change": "resolver",
|
||||
"count": 1,
|
||||
"epoch": 401364,
|
||||
"event": "condition-changed",
|
||||
"evidence": [
|
||||
{
|
||||
"at": "2026-10-29T18:31:33.979579505Z",
|
||||
"said": "no answer for node-files from g14 to the bus's discovery"
|
||||
}
|
||||
],
|
||||
"explanation": "Nothing for you to do. Whatever serves node-files on g14 does not answer, so its tools do nothing there. A machine asleep is normal.",
|
||||
"headline": "Node-files on g14 is not answering",
|
||||
"key": "seat.node-files.g14.silent",
|
||||
"kind": "holder-silent",
|
||||
"last-observed": "2026-10-29T18:31:33.979579Z",
|
||||
"needs": "",
|
||||
"observations": 1,
|
||||
"raised": "2026-10-29T18:31:33.979579505Z",
|
||||
"resolved": "Node-files on g14 answers again",
|
||||
"resolver": "healer:H3",
|
||||
"severity": "warning",
|
||||
"show": "mesh-controller.conditions key=seat.node-files.g14.silent",
|
||||
"silenced": null,
|
||||
"source": "D3",
|
||||
"subject": {
|
||||
"id": "node-files.g14",
|
||||
"machine": "g14",
|
||||
"scope": "seat"
|
||||
},
|
||||
"summary": "node-files's holder on g14 does not answer the bus: its verbs reach nothing there",
|
||||
"tried": [
|
||||
{
|
||||
"at": "2026-10-29T18:31:48.308721256Z",
|
||||
"by": "healer H3",
|
||||
"outcome": "acted: every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there (act 1 of 1 within 1h0m0s)",
|
||||
"what": "asserted the bus's streams, consumers and seat workers again"
|
||||
}
|
||||
],
|
||||
"was": "self",
|
||||
"why": "acted: every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there (act 1 of 1 within 1h0m0s)"
|
||||
},
|
||||
{
|
||||
"actions": null,
|
||||
"at": "2026-10-28T20:56:51.686413523Z",
|
||||
"change": "severity",
|
||||
"count": 1,
|
||||
"epoch": 387175,
|
||||
"event": "condition-changed",
|
||||
"evidence": [
|
||||
{
|
||||
"at": "2026-10-28T20:55:13.087322533Z",
|
||||
"said": "no answer for mesh-placement from ace to the bus's discovery"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-28T20:50:11.256396519Z",
|
||||
"said": "no answer for mesh-placement from ace to the bus's discovery"
|
||||
}
|
||||
],
|
||||
"explanation": "Needs you: the mesh tried to repair this and could not; read the details to decide what to do. Whatever serves mesh-placement on ace does not answer, so its tools do nothing there. A machine asleep is normal.",
|
||||
"headline": "Mesh-placement on ace is not answering",
|
||||
"key": "seat.mesh-placement.ace.silent",
|
||||
"kind": "holder-silent",
|
||||
"last-observed": "2026-10-28T20:55:13.087322Z",
|
||||
"needs": "the mesh tried to repair this and could not; read the details to decide what to do.",
|
||||
"observations": 2,
|
||||
"raised": "2026-10-28T20:50:11.256396519Z",
|
||||
"resolved": "Mesh-placement on ace answers again",
|
||||
"resolver": "operator",
|
||||
"severity": "urgent",
|
||||
"show": "mesh-controller.conditions key=seat.mesh-placement.ace.silent",
|
||||
"silenced": null,
|
||||
"source": "D3",
|
||||
"subject": {
|
||||
"id": "mesh-placement.ace",
|
||||
"machine": "ace",
|
||||
"scope": "seat"
|
||||
},
|
||||
"summary": "mesh-placement's holder on ace does not answer the bus: its verbs reach nothing there",
|
||||
"tried": [
|
||||
{
|
||||
"at": "2026-10-28T20:50:22.435049179Z",
|
||||
"by": "healer H3",
|
||||
"outcome": "acted: every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there (act 1 of 1 within 1h0m0s)",
|
||||
"what": "asserted the bus's streams, consumers and seat workers again"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-28T20:56:51.686413523Z",
|
||||
"by": "healer H3",
|
||||
"outcome": "escalated: its budget of 1 act(s) within 1h0m0s is spent and seat.mesh-placement.ace.silent is still open: acted at 20:50 UTC (asserted the bus's streams, consumers and seat workers again \u2014 every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there)",
|
||||
"what": "handed to the operator: nothing in the mesh will repair it now"
|
||||
}
|
||||
],
|
||||
"was": "warning",
|
||||
"why": "escalated: its budget of 1 act(s) within 1h0m0s is spent and seat.mesh-placement.ace.silent is still open: acted at 20:50 UTC (asserted the bus's streams, consumers and seat workers again \u2014 every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there)"
|
||||
},
|
||||
{
|
||||
"actions": null,
|
||||
"at": "2026-10-28T21:18:48.630162912Z",
|
||||
"change": "cleared",
|
||||
"cleared": "2026-10-28T21:18:48.630162912Z",
|
||||
"count": 2,
|
||||
"epoch": 387463,
|
||||
"event": "condition-cleared",
|
||||
"evidence": [
|
||||
{
|
||||
"at": "2026-10-28T21:18:16.589232939Z",
|
||||
"said": "82 pending ([82 on EVENTS]), last taken 2026-10-28T21:15:22Z"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-28T21:17:46.778840937Z",
|
||||
"said": "82 pending ([82 on EVENTS]), last taken 2026-10-28T21:15:22Z"
|
||||
}
|
||||
],
|
||||
"explanation": "Needs you: restart the controller if this stays, from the mesh MCP server; this notification cannot do it. The controller, which coordinates the mesh, has taken no messages for minutes while some wait. Changes and repairs do not happen until it recovers.",
|
||||
"first": "2026-10-28T21:13:16.626696927Z",
|
||||
"gaps": [
|
||||
{
|
||||
"cleared": "2026-10-28T21:13:47.735189608Z",
|
||||
"reopened": "2026-10-28T21:17:46.778840937Z"
|
||||
}
|
||||
],
|
||||
"headline": "The controller stopped listening",
|
||||
"key": "core.controller.deaf",
|
||||
"kind": "controller-deaf",
|
||||
"last-observed": "2026-10-28T21:18:16.589232Z",
|
||||
"needs": "restart the controller if this stays, from the mesh MCP server; this notification cannot do it.",
|
||||
"observations": 2,
|
||||
"raised": "2026-10-28T21:17:46.778840937Z",
|
||||
"resolved": "The controller listens again",
|
||||
"resolver": "self",
|
||||
"severity": "urgent",
|
||||
"show": "mesh-controller.conditions key=core.controller.deaf",
|
||||
"silenced": null,
|
||||
"source": "S4",
|
||||
"subject": {
|
||||
"id": "controller",
|
||||
"machine": "novox",
|
||||
"scope": "core"
|
||||
},
|
||||
"summary": "the controller's event loop has taken nothing for 3m0s while its consumers hold 82 message(s): reports, builds and merges are not being acted on",
|
||||
"why": "S4 no longer observes it"
|
||||
}
|
||||
]
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
// Log — a module's record of operations on the bus, kept as long as the log is (novox/hq ADR 0297). Not the
|
||||
// current value: that is state, and a log's state is its fold. Each entry is one operation on one key, its
|
||||
// sequence in the log is its identity, and the order of the log is the order of the operations. A module
|
||||
// declares each log it owns in its manifest (`logs`) and names it here as it named it there; a log is its
|
||||
// owner's alone, and the runtime refuses, with the reason, one it did not issue.
|
||||
// declares each log it owns in its manifest (`logs`) and names it here as it named it there; only its owner appends.
|
||||
// Another module's log is read by naming it `<module>.<log>` in the manifest's `reads` and here (novox/hq ADR 0571
|
||||
// clause 1): read, newest per key, from a time, and followed. The runtime refuses, with the reason, one it did not issue.
|
||||
|
||||
// LogEntry is one operation on one key, as the log holds it.
|
||||
type LogEntry struct {
|
||||
@@ -40,7 +41,7 @@ type LogInfo struct {
|
||||
// KeptLog is one log this module owns.
|
||||
type KeptLog struct{ name string }
|
||||
|
||||
// Log is a log this module owns, by its local name.
|
||||
// Log is a log this module owns, by its local name, or reads, `<module>.<log>`.
|
||||
func Log(name string) KeptLog { return KeptLog{name: name} }
|
||||
|
||||
const logTimeout = 30 * time.Second
|
||||
@@ -121,3 +122,102 @@ func (l KeptLog) Info() (*LogInfo, error) {
|
||||
var i LogInfo
|
||||
return &i, json.Unmarshal(raw, &i)
|
||||
}
|
||||
|
||||
// Newest is the newest entry of every key keys names (a key, `*` for one name, `**` for the rest; empty is every key),
|
||||
// in the log's order: runtime verb `mesh/log.newest` {log, keys}, answered as a list of entries.
|
||||
func (l KeptLog) Newest(keys string) ([]LogEntry, error) {
|
||||
params := map[string]any{}
|
||||
if keys != "" {
|
||||
params["keys"] = keys
|
||||
}
|
||||
raw, err := l.ask("newest", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []LogEntry
|
||||
return out, json.Unmarshal(raw, &out)
|
||||
}
|
||||
|
||||
// Since is one page of the entries from a time on, as Read pages them: runtime verb `mesh/log.read` with `since` (RFC
|
||||
// 3339) in place of `from`.
|
||||
func (l KeptLog) Since(at time.Time, keys string, limit int) (*LogPage, error) {
|
||||
if limit < 0 {
|
||||
return nil, fmt.Errorf("a read of %s asks for at least no entries: limit %d", l.name, limit)
|
||||
}
|
||||
params := map[string]any{"since": at.UTC().Format(time.RFC3339Nano)}
|
||||
if keys != "" {
|
||||
params["keys"] = keys
|
||||
}
|
||||
if limit > 0 {
|
||||
params["limit"] = limit
|
||||
}
|
||||
raw, err := l.ask("read", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p LogPage
|
||||
return &p, json.Unmarshal(raw, &p)
|
||||
}
|
||||
|
||||
// LogFrom is where a log's watch starts: LogFromNewest (the newest entry of each key, then every new one), LogFromAll
|
||||
// (every entry the log keeps), LogFromSince (every entry from a time) or LogFromNow (only new entries).
|
||||
type LogFrom struct {
|
||||
Kind string
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
// The kinds of LogFrom.
|
||||
const (
|
||||
LogFromNewestKind = "newest"
|
||||
LogFromAllKind = "all"
|
||||
LogFromSinceKind = "since"
|
||||
LogFromNowKind = "now"
|
||||
)
|
||||
|
||||
var (
|
||||
LogFromNewest = LogFrom{Kind: LogFromNewestKind}
|
||||
LogFromAll = LogFrom{Kind: LogFromAllKind}
|
||||
LogFromNow = LogFrom{Kind: LogFromNowKind}
|
||||
)
|
||||
|
||||
// LogFromSince starts a watch at a time.
|
||||
func LogFromSince(at time.Time) LogFrom { return LogFrom{Kind: LogFromSinceKind, Since: at} }
|
||||
|
||||
func (f LogFrom) params() (map[string]any, error) {
|
||||
p := map[string]any{"from": f.Kind}
|
||||
switch f.Kind {
|
||||
case LogFromNewestKind, LogFromAllKind, LogFromNowKind:
|
||||
case LogFromSinceKind:
|
||||
p["since"] = f.Since.UTC().Format(time.RFC3339Nano)
|
||||
default:
|
||||
return nil, fmt.Errorf("a log's watch starts from newest, all, since or now, not %q", f.Kind)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Watch hands handle each entry of keys from where from says, in the log's order, and then every new one as it is
|
||||
// appended, and returns once the entries there at its start have been handled: runtime verb `mesh/log.watch` {log,
|
||||
// keys, from, since}, each entry then sent as `mesh/log` {watch, entry}. Event-driven: nothing reads the log again on
|
||||
// a timer. The watch lasts as long as the process.
|
||||
func (l KeptLog) Watch(keys string, from LogFrom, handle func(LogEntry) error) error {
|
||||
params, err := from.params()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := live()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := c.newID()
|
||||
c.mu.Lock()
|
||||
c.logWatches[id] = handle
|
||||
c.mu.Unlock()
|
||||
params["log"], params["keys"] = l.name, keys
|
||||
if _, err := c.askAs(id, "mesh/log.watch", params, logTimeout); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.logWatches, id)
|
||||
c.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+51
-5
@@ -58,12 +58,13 @@ func (r *logRuntime) answer(m message) map[string]any {
|
||||
From uint64 `json:"from"`
|
||||
Keys string `json:"keys"`
|
||||
Limit int `json:"limit"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &p)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.asked = append(r.asked, m)
|
||||
if p.Log != "changes" {
|
||||
if p.Log != "changes" && p.Log != "builder.build-log" {
|
||||
return map[string]any{"error": map[string]any{"code": -32000, "message": "issues was issued no log " + p.Log}}
|
||||
}
|
||||
switch strings.TrimPrefix(m.Method, "mesh/log.") {
|
||||
@@ -83,7 +84,7 @@ func (r *logRuntime) answer(m message) map[string]any {
|
||||
page := map[string]any{"entries": []LogEntry{}, "next": 0, "last": len(r.entries)}
|
||||
var got []LogEntry
|
||||
for _, e := range r.entries {
|
||||
if e.Sequence < p.From || !matches(p.Keys, e.Key) {
|
||||
if e.Sequence < p.From || !matches(p.Keys, e.Key) || e.At.Before(p.Since) {
|
||||
continue
|
||||
}
|
||||
if len(got) == limit {
|
||||
@@ -96,6 +97,16 @@ func (r *logRuntime) answer(m message) map[string]any {
|
||||
page["entries"] = got
|
||||
}
|
||||
return map[string]any{"result": page}
|
||||
case "newest":
|
||||
var out []LogEntry
|
||||
for _, e := range r.entries {
|
||||
if matches(p.Keys, e.Key) && r.last(e.Key) == e.Sequence {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return map[string]any{"result": out}
|
||||
case "watch":
|
||||
return map[string]any{"result": map[string]any{}}
|
||||
case "last":
|
||||
seq := r.last(p.Key)
|
||||
if seq == 0 {
|
||||
@@ -148,6 +159,13 @@ func logging(t *testing.T, r *logRuntime, run func() (any, error)) map[string]an
|
||||
a := r.answer(m)
|
||||
a["id"] = m.ID
|
||||
send(a)
|
||||
// A watch's entries follow its answer, each named by the watch's id (ADR 0571 clause 1).
|
||||
if m.Method == "mesh/log.watch" {
|
||||
r.mu.Lock()
|
||||
e := r.entries[len(r.entries)-1]
|
||||
r.mu.Unlock()
|
||||
send(map[string]any{"id": "entry-1", "method": "mesh/log", "params": map[string]any{"watch": m.ID, "entry": e}})
|
||||
}
|
||||
}(m)
|
||||
continue
|
||||
}
|
||||
@@ -193,7 +211,9 @@ func TestABundleKeepsALogThroughTheRuntime(t *testing.T) {
|
||||
pages []*LogPage
|
||||
last, none *LogEntry
|
||||
info *LogInfo
|
||||
narrowed *LogPage
|
||||
narrowed, since *LogPage
|
||||
newest []LogEntry
|
||||
watched *LogEntry
|
||||
)
|
||||
got := logging(t, r, func() (any, error) {
|
||||
changes := Log("changes")
|
||||
@@ -232,8 +252,27 @@ func TestABundleKeepsALogThroughTheRuntime(t *testing.T) {
|
||||
if none, err = changes.Last("999"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err = changes.Info()
|
||||
return "done", err
|
||||
if info, err = changes.Info(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ADR 0571 clause 1: another module's log, newest per key, from a time, and followed.
|
||||
if newest, err = changes.Newest(""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if since, err = changes.Since(time.Date(2026, 10, 11, 18, 0, 5, 0, time.UTC), "", 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
heard := make(chan LogEntry, 1)
|
||||
if err = Log("builder.build-log").Watch("letta", LogFromSince(time.Date(2026, 10, 11, 18, 0, 0, 0, time.UTC)),
|
||||
func(e LogEntry) error { heard <- e; return nil }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case e := <-heard:
|
||||
watched = &e
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
return "done", nil
|
||||
})
|
||||
if out, isError := text(t, got); isError || out != `"done"` {
|
||||
t.Fatalf("the tool answered %s", out)
|
||||
@@ -296,6 +335,13 @@ func TestABundleKeepsALogThroughTheRuntime(t *testing.T) {
|
||||
if none != nil {
|
||||
t.Fatalf("a key with no entry has a last: %+v", none)
|
||||
}
|
||||
if len(newest) != 5 || newest[0].Key != "402" || newest[1].Sequence != 3 || len(since.Entries) != 2 || since.Entries[0].Sequence != 5 {
|
||||
t.Fatalf("the newest per key are %+v, since a time %+v", newest, since)
|
||||
}
|
||||
if method, p = r.params(t, 15); method != "mesh/log.watch" || p["log"] != "builder.build-log" || p["keys"] != "letta" ||
|
||||
p["from"] != "since" || p["since"] != "2026-10-11T18:00:00Z" || watched == nil || watched.Sequence != 6 {
|
||||
t.Fatalf("a watch asked %s %v and heard %+v", method, p, watched)
|
||||
}
|
||||
if info == nil || info.Entries != 6 || info.MaxBytes != 1<<30 || info.First != 1 || info.Last != 6 || info.Bytes == 0 {
|
||||
t.Fatalf("the log's info is %+v", info)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,14 @@ var sendGrants = []string{
|
||||
"mesh.seat.operator-channel.call.tell.novox~mesh-mail~send",
|
||||
"mesh.seat.operator-channel.tool.tell",
|
||||
"mesh.seat.operator-channel.tool.tell.*",
|
||||
// And the builder's log, as buscompose's readLogGrants grants a declared read of it (novox/hq ADR 0571 clause 1).
|
||||
"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_build-log",
|
||||
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log",
|
||||
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.>",
|
||||
"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.>",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.*",
|
||||
"$JS.API.CONSUMER.INFO.LOG_mesh-delivery-builder_build-log.*",
|
||||
"$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.*",
|
||||
}
|
||||
|
||||
// grantedSendProcess is a test bus with JetStream, with two users: novox.mesh-mail.send, permissioned to
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// **Another module's log, read on this process's own credential** (novox/hq ADR 0571 clause 1; the lead, 2026-10-29): a
|
||||
// log the module's manifest names in `reads`, `<module>.<log>`, read through an ordered consumer this connection creates
|
||||
// and lets go of, never a durable one. Newest is the newest entry of each key, Since every entry from a time, and Watch
|
||||
// those or every entry, then each new one as it is appended: event-driven, nothing reads the log again on a timer. The
|
||||
// grants are buscompose's readLogGrants, given for a declared read of a log alone.
|
||||
|
||||
// LogReader is one module's log this connection reads.
|
||||
type LogReader struct {
|
||||
Module, Name string
|
||||
js func() (jetstream.JetStream, error)
|
||||
closed func() bool
|
||||
}
|
||||
|
||||
// ReadLog is module's log name, read on this connection.
|
||||
func (c *Conn) ReadLog(module, name string) LogReader {
|
||||
return LogReader{Module: module, Name: name, closed: func() bool { return c.nc.IsClosed() },
|
||||
js: func() (jetstream.JetStream, error) { return jetstream.New(c.nc) }}
|
||||
}
|
||||
|
||||
func (l LogReader) stream() string { return "LOG_" + l.Module + "_" + l.Name }
|
||||
func (l LogReader) subject() string { return "mesh.log." + l.Module + "." + l.Name }
|
||||
|
||||
// filter is keys as a subject filter: a key, `*` for one name, `**` for the rest; empty is every key.
|
||||
func (l LogReader) filter(keys string) string {
|
||||
if keys == "" {
|
||||
return l.subject() + ".>"
|
||||
}
|
||||
return l.subject() + "." + watchPattern(keys)
|
||||
}
|
||||
|
||||
func (l LogReader) config(keys string, from stdio.LogFrom) (jetstream.OrderedConsumerConfig, error) {
|
||||
cfg := jetstream.OrderedConsumerConfig{FilterSubjects: []string{l.filter(keys)}}
|
||||
switch from.Kind {
|
||||
case stdio.LogFromNewestKind:
|
||||
cfg.DeliverPolicy = jetstream.DeliverLastPerSubjectPolicy
|
||||
case stdio.LogFromAllKind:
|
||||
cfg.DeliverPolicy = jetstream.DeliverAllPolicy
|
||||
case stdio.LogFromSinceKind:
|
||||
at := from.Since
|
||||
cfg.DeliverPolicy, cfg.OptStartTime = jetstream.DeliverByStartTimePolicy, &at
|
||||
case stdio.LogFromNowKind:
|
||||
cfg.DeliverPolicy = jetstream.DeliverNewPolicy
|
||||
default:
|
||||
return cfg, fmt.Errorf("%s's log %s: a read starts from newest, all, since or now, not %q", l.Module, l.Name, from.Kind)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (l LogReader) entry(m jetstream.Msg) (stdio.LogEntry, error) {
|
||||
meta, err := m.Metadata()
|
||||
if err != nil {
|
||||
return stdio.LogEntry{}, err
|
||||
}
|
||||
return stdio.LogEntry{Sequence: meta.Sequence.Stream, Key: strings.TrimPrefix(m.Subject(), l.subject()+"."),
|
||||
Value: append([]byte(nil), m.Data()...), At: meta.Timestamp}, nil
|
||||
}
|
||||
|
||||
// Newest is the newest entry of each key keys names, in the log's order.
|
||||
func (l LogReader) Newest(keys string) ([]stdio.LogEntry, error) {
|
||||
return l.collect(keys, stdio.LogFromNewest)
|
||||
}
|
||||
|
||||
// Since is every entry of keys from at on, in the log's order.
|
||||
func (l LogReader) Since(at time.Time, keys string) ([]stdio.LogEntry, error) {
|
||||
return l.collect(keys, stdio.LogFromSince(at))
|
||||
}
|
||||
|
||||
func (l LogReader) collect(keys string, from stdio.LogFrom) ([]stdio.LogEntry, error) {
|
||||
var out []stdio.LogEntry
|
||||
stop, err := l.watch(keys, from, func(e stdio.LogEntry) error { out = append(out, e); return nil }, false)
|
||||
if stop != nil {
|
||||
stop()
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Watch hands handle each entry from where from says and then every new one, in the log's order, and returns once the
|
||||
// entries there at its start have been handled. The watch lasts as long as the connection; an error from handle ends
|
||||
// it, as the connection closing does. Before the entries at its start are handled, either is returned as the error.
|
||||
func (l LogReader) Watch(keys string, from stdio.LogFrom, handle func(stdio.LogEntry) error) error {
|
||||
_, err := l.watch(keys, from, handle, true)
|
||||
return err
|
||||
}
|
||||
|
||||
func (l LogReader) watch(keys string, from stdio.LogFrom, handle func(stdio.LogEntry) error, follow bool) (func(), error) {
|
||||
cfg, err := l.config(keys, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
js, err := l.js()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bind, stopBind := context.WithTimeout(context.Background(), stateTimeout)
|
||||
defer stopBind()
|
||||
cons, err := js.OrderedConsumer(bind, l.stream(), cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s's log %s (stream %s): %w", l.Module, l.Name, l.stream(), err)
|
||||
}
|
||||
info, err := cons.Info(bind)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s's log %s (stream %s): %w", l.Module, l.Name, l.stream(), err)
|
||||
}
|
||||
pending := info.NumPending
|
||||
it, err := cons.Messages()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s's log %s (stream %s): %w", l.Module, l.Name, l.stream(), err)
|
||||
}
|
||||
for ; pending > 0; pending-- {
|
||||
m, err := it.Next()
|
||||
if err == nil {
|
||||
var e stdio.LogEntry
|
||||
if e, err = l.entry(m); err == nil {
|
||||
err = handle(e)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
it.Stop()
|
||||
return nil, fmt.Errorf("%s's log %s: %w", l.Module, l.Name, err)
|
||||
}
|
||||
}
|
||||
if !follow {
|
||||
return it.Stop, nil
|
||||
}
|
||||
go func() {
|
||||
defer it.Stop()
|
||||
for {
|
||||
m, err := it.Next()
|
||||
if err != nil || (l.closed != nil && l.closed()) {
|
||||
return
|
||||
}
|
||||
e, err := l.entry(m)
|
||||
if err != nil || handle(e) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return it.Stop, nil
|
||||
}
|
||||
@@ -113,6 +113,51 @@ func (s State) Create(key string, value any) (uint64, error) {
|
||||
return rev, nil
|
||||
}
|
||||
|
||||
// MinTTL is the shortest time to live a key may be given: the bus counts a key's age in whole seconds.
|
||||
const MinTTL = time.Second
|
||||
|
||||
// CreateTTL writes a key only when it has none, with its own time to live (novox/hq ADR 0564 clauses 4 and 7, issue
|
||||
// 1952 S6): the bus removes it when ttl passes and its watchers hear the removal, so an expiry is an event and no timer
|
||||
// runs. A key that has a value is refused as stdio.ErrChanged: an act begun once cannot be begun again. The bucket must
|
||||
// take keys with their own time to live (a state declared `key-ttl`).
|
||||
func (s State) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) {
|
||||
if ttl < MinTTL {
|
||||
return 0, fmt.Errorf("%s's state %s, key %s: a time to live of %s is shorter than the bus keeps (%s)", s.Module, s.Name, key, ttl, MinTTL)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), stateTimeout)
|
||||
defer cancel()
|
||||
kv, err := s.store(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := kv.Create(ctx, key, data, jetstream.KeyTTL(ttl))
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s's state %s, key %s: %w", s.Module, s.Name, key, err)
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
|
||||
// RenewTTL writes a key whole with a fresh time to live, whatever it held: a pulse its signal renews (ADR 0564 clause
|
||||
// 4). A time to live is set only when a key is created, so a live key is deleted first; its watchers hear the delete and
|
||||
// then the new value.
|
||||
func (s State) RenewTTL(key string, value any, ttl time.Duration) (uint64, error) {
|
||||
rev, err := s.CreateTTL(key, value, ttl)
|
||||
if !errors.Is(err, stdio.ErrChanged) {
|
||||
return rev, err
|
||||
}
|
||||
if err := s.Delete(key); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.CreateTTL(key, value, ttl)
|
||||
}
|
||||
|
||||
// Update writes a key only when it is still at revision, and returns the new one; a key changed meanwhile
|
||||
// is refused as stdio.ErrChanged.
|
||||
func (s State) Update(key string, value any, revision uint64) (uint64, error) {
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestStateCreateGetUpdateOnAGrantedBucket(t *testing.T) {
|
||||
if _, err := awaited.Put(ctxFor(t), "hearing", []byte(`{"heard":true}`)); !errors.Is(err, ErrNotYet) {
|
||||
t.Fatalf("a second write before the bucket exists: %v, want ErrNotYet", err)
|
||||
}
|
||||
if _, err := adminJetStream.CreateKeyValue(ctxFor(t), jetstream.KeyValueConfig{Bucket: "mesh-mail_sends"}); err != nil {
|
||||
if _, err := adminJetStream.CreateKeyValue(ctxFor(t), jetstream.KeyValueConfig{Bucket: "mesh-mail_sends", LimitMarkerTTL: time.Second}); err != nil {
|
||||
t.Fatalf("the admin could not make the bucket: %v", err)
|
||||
}
|
||||
select {
|
||||
@@ -94,6 +94,22 @@ func TestStateCreateGetUpdateOnAGrantedBucket(t *testing.T) {
|
||||
if _, err := s.Create("key", map[string]any{"v": 4}); !errors.Is(err, stdio.ErrChanged) {
|
||||
t.Fatalf("Create of an existing key: %v, want stdio.ErrChanged", err)
|
||||
}
|
||||
|
||||
// 1952 S6 (ADR 0564 clause 4): a key created with its own time to live is gone once it passes, on the bus itself.
|
||||
if _, err := s.CreateTTL("pulse", map[string]any{"v": 5}, time.Second); err != nil {
|
||||
t.Fatalf("CreateTTL on a bucket that takes keys with a time to live: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
e, err := s.Get("pulse")
|
||||
if err == nil && e == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the key created with a second to live still reads %+v, %v after 5s", e, err)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// 1188/AC4: given an account not granted a bucket, when it writes there, then the bus's refusal comes
|
||||
@@ -258,6 +274,44 @@ func TestStateWatchOnAGrantedBucket(t *testing.T) {
|
||||
t.Fatalf("narrowed watch got %+v for a key it did not name", c)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
|
||||
// ADR 0571 clause 1: another module's log, read on the process's own credential with exactly the read grants: the
|
||||
// newest per key, every entry from a time, and a watch from the newest that then hears an appended entry.
|
||||
if _, err := adminJetStream.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_mesh-delivery-builder_build-log",
|
||||
Subjects: []string{"mesh.log.mesh-delivery-builder.build-log.>"}, AllowDirect: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"letta", "gitea", "letta"} {
|
||||
if _, err := adminJetStream.Publish(ctx, "mesh.log.mesh-delivery-builder.build-log."+k, []byte(`{"module":"`+k+`"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
builds := conn.ReadLog("mesh-delivery-builder", "build-log")
|
||||
newest, err := builds.Newest("")
|
||||
if err != nil || len(newest) != 2 || newest[0].Key != "gitea" || newest[1].Sequence != 3 {
|
||||
t.Fatalf("the newest per key are %+v: %v", newest, err)
|
||||
}
|
||||
since, err := builds.Since(newest[0].At, "letta")
|
||||
if err != nil || len(since) != 1 || since[0].Sequence != 3 {
|
||||
t.Fatalf("letta since gitea's build is %+v: %v", since, err)
|
||||
}
|
||||
heard := make(chan stdio.LogEntry, 4)
|
||||
if err := builds.Watch("letta", stdio.LogFromNewest, func(e stdio.LogEntry) error { heard <- e; return nil }); err != nil || len(heard) != 1 {
|
||||
t.Fatalf("a watch from the newest handed %d at its start: %v", len(heard), err)
|
||||
}
|
||||
<-heard
|
||||
if _, err := adminJetStream.Publish(ctx, "mesh.log.mesh-delivery-builder.build-log.letta", []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case e := <-heard:
|
||||
if e.Sequence != 4 || e.Key != "letta" {
|
||||
t.Fatalf("the watch heard %+v", e)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the watch did not hear the appended entry")
|
||||
}
|
||||
}
|
||||
|
||||
// 1840/AC8: given an account not granted a bucket, when it watches it, then the refusal names the bucket.
|
||||
|
||||
@@ -38,6 +38,8 @@ type fakeKV struct {
|
||||
// initial is what a Watch hands first, before its nil marker; watcher is the last one made.
|
||||
initial []jetstream.KeyValueEntry
|
||||
watcher *fakeWatcher
|
||||
// withTTL says the last create of a key carried an option: its time to live.
|
||||
withTTL map[string]bool
|
||||
}
|
||||
|
||||
func newFakeKV() *fakeKV { return &fakeKV{values: map[string][]byte{}, revs: map[string]uint64{}} }
|
||||
@@ -52,9 +54,13 @@ func (f *fakeKV) Get(_ context.Context, key string) (jetstream.KeyValueEntry, er
|
||||
return fakeEntry{key: key, value: v, rev: f.revs[key]}, nil
|
||||
}
|
||||
|
||||
func (f *fakeKV) Create(_ context.Context, key string, value []byte, _ ...jetstream.KVCreateOpt) (uint64, error) {
|
||||
func (f *fakeKV) Create(_ context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.withTTL == nil {
|
||||
f.withTTL = map[string]bool{}
|
||||
}
|
||||
f.withTTL[key] = len(opts) > 0
|
||||
if _, ok := f.values[key]; ok {
|
||||
return 0, jetstream.ErrKeyExists
|
||||
}
|
||||
@@ -160,6 +166,28 @@ func TestStateSatisfiesAskerStore(t *testing.T) {
|
||||
if rev2 == rev {
|
||||
t.Fatal("Update did not advance the revision")
|
||||
}
|
||||
|
||||
// 1952 S6 (ADR 0564 clauses 4, 7): a key created with its own time to live is created once, a second create
|
||||
// refused as an act begun already; a renewal writes it whole with a fresh time to live; a time to live the bus
|
||||
// cannot keep is refused.
|
||||
kv := newFakeKV()
|
||||
s = stateOn(kv)
|
||||
if _, err := s.CreateTTL("settle.H3", map[string]any{"a": 1}, time.Minute); err != nil || !kv.withTTL["settle.H3"] {
|
||||
t.Fatalf("CreateTTL: %v, with a time to live %v", err, kv.withTTL["settle.H3"])
|
||||
}
|
||||
if _, err := s.CreateTTL("settle.H3", map[string]any{"a": 2}, time.Minute); !errors.Is(err, stdio.ErrChanged) {
|
||||
t.Fatalf("CreateTTL of a live key: %v, want stdio.ErrChanged", err)
|
||||
}
|
||||
kv.withTTL["settle.H3"] = false
|
||||
if _, err := s.RenewTTL("settle.H3", map[string]any{"a": 3}, time.Minute); err != nil || !kv.withTTL["settle.H3"] {
|
||||
t.Fatalf("RenewTTL: %v, with a time to live %v", err, kv.withTTL["settle.H3"])
|
||||
}
|
||||
if e, _ := s.Get("settle.H3"); e == nil || string(e.Value) != `{"a":3}` {
|
||||
t.Fatalf("after RenewTTL the key reads %+v", e)
|
||||
}
|
||||
if _, err := s.CreateTTL("pulse.S1", 1, 10*time.Millisecond); err == nil || !strings.Contains(err.Error(), "shorter") {
|
||||
t.Fatalf("a time to live under a second: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1840/S1: given a state, when a key is put twice, then each Put returns a new revision and the key reads
|
||||
|
||||
@@ -39,8 +39,7 @@ func TestTheConsumersFileAnswersEachConsumersCredential(t *testing.T) {
|
||||
|
||||
// Wired as the helper's Vault, the file is what makes each consumer's login.
|
||||
bus := newStandIn()
|
||||
bus.Put(context.Background(), AssignmentsBucket, "ace.letta", []byte(`{"node":"ace","module":"letta",
|
||||
"provisioning":{"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres"}]}}`))
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
var made []string
|
||||
p := New(Options{Node: "novox", Module: "postgres", Bus: bus, Vault: v,
|
||||
Offers: map[string]Offer{"postgres-database": {Serves: map[string]any{"port": 5432}}},
|
||||
|
||||
@@ -1,51 +1,9 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 1098: an assignment with `ended` set, or with a `state`, is not a standing assignment: the helper reads it
|
||||
// as a removed key, so the entry of its consumer is removed and the adapter hears it is gone. The fields the
|
||||
// controller adds change nothing.
|
||||
func TestAnEndedAssignmentIsReadAsARemovedKey(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
p := postgres(bus, v, m)
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.postgres-database"}) {
|
||||
t.Fatalf("entries %v", got)
|
||||
}
|
||||
for _, mark := range []map[string]any{{"ended": "2026-10-18T01:00:00Z"}, {"state": "pending"}} {
|
||||
raw := bus.get(AssignmentsBucket, "ace.letta")
|
||||
var a map[string]any
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range mark {
|
||||
a[k] = v
|
||||
}
|
||||
raw, _ = json.Marshal(a)
|
||||
_ = bus.Put(context.Background(), AssignmentsBucket, "ace.letta", raw)
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bus.keys(BucketOf("postgres")); len(got) != 0 || len(out) != 1 || out[0].Status != Removed {
|
||||
t.Fatalf("%v: entries %v, outcomes %+v", mark, got, out)
|
||||
}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandingIsFalseOnlyForAnEndedOrPendingValue(t *testing.T) {
|
||||
for raw, want := range map[string]bool{
|
||||
`{"node":"a","module":"b"}`: true,
|
||||
|
||||
@@ -105,7 +105,8 @@ func TestAnUnreadableGrantFileRetiresNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A login the grant file stops giving is retired by the usual rules: stable, then retired, never dropped.
|
||||
// A login the grant file stops giving is not retired by the provider itself (novox/hq ADR 0570 clause 5): every
|
||||
// retirement waits for the operator's yes on a provision's key.
|
||||
func TestALoginTheGrantFileStopsGivingIsRetired(t *testing.T) {
|
||||
bound, _ := bindings(1)
|
||||
w := newMovedWorld(t, bound)
|
||||
@@ -117,7 +118,7 @@ func TestALoginTheGrantFileStopsGivingIsRetired(t *testing.T) {
|
||||
w.pass()
|
||||
w.granting(map[string]any{"as": "mesh_ace_baserow", "node": "ace", "values": map[string]any{"name": "baserow"}})
|
||||
w.settled()
|
||||
if !reflect.DeepEqual(w.a.removed, []string{"mesh_ace_letta"}) || len(w.a.deleted) != 0 {
|
||||
t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted)
|
||||
if len(w.a.removed) != 0 || len(w.a.deleted) != 0 {
|
||||
t.Fatalf("retired %v, deleted %v, by itself", w.a.removed, w.a.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ package harness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
// MovedSpec is what differs per provider on the moved path.
|
||||
@@ -57,17 +57,17 @@ type Moved struct {
|
||||
// FirstRetry is the first wait before a watch asked too early is asked again (issue 943); a second when zero.
|
||||
FirstRetry time.Duration
|
||||
|
||||
mu sync.Mutex // guards assignments and loaded
|
||||
assignments map[string][]byte
|
||||
loaded bool
|
||||
verified map[string]time.Time
|
||||
mu sync.Mutex // guards plans and loaded
|
||||
plans map[string][]byte
|
||||
loaded bool
|
||||
verified map[string]time.Time
|
||||
}
|
||||
|
||||
func newMoved(h *Harness, spec MovedSpec) *Moved {
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
h.mu.Unlock()
|
||||
return &Moved{h: h, spec: spec, assignments: map[string][]byte{}, verified: map[string]time.Time{}}
|
||||
return &Moved{h: h, spec: spec, plans: map[string][]byte{}, verified: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// Make brings one consumer's resource into being under its login and the vault's credential, as the harness does:
|
||||
@@ -122,77 +122,50 @@ func (m *Moved) Make(ctx context.Context, c provider.Consumer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gone is the helper asking whether it may forget a consumer that no longer binds. **It never retires**: retiring is
|
||||
// the harness's rule, the same unasked set over StablePasses passes and StableFor, at most RetireAtOnce at a time
|
||||
// (retirement.go, ADR 0230), run by retire below. So a consumer missing from one read of the plans loses nothing:
|
||||
// the helper is told no, and asks again at its next pass, until the login is retired, or asked for again.
|
||||
func (m *Moved) Gone(_ context.Context, e provider.Entry) error {
|
||||
// Gone is the helper letting a consumer go, which it does only on a retiring provision the operator said yes to (novox/hq
|
||||
// ADR 0570 clause 5): its login is retired at once, through the adapter, its resource and data kept and marked to
|
||||
// delete, deleted only by a person's `cleanup delete`. A consumer another plan still binds under the same login loses
|
||||
// nothing.
|
||||
func (m *Moved) Gone(ctx context.Context, e provider.Entry) error {
|
||||
h := m.h
|
||||
as := m.spec.Login(e)
|
||||
if as == "" {
|
||||
return nil // refused: nothing was made for it
|
||||
}
|
||||
// The plans and the grant file as they are now, not as the last retirement pass read them: with either not
|
||||
// read, nothing is let go.
|
||||
want, read := m.asked(h.Node)
|
||||
want, _ := m.asked(h.Node)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
asked := want[as]
|
||||
if m.spec.WithGrants {
|
||||
_, granted, ok := m.granted(want)
|
||||
read = read && ok
|
||||
asked = asked || granted[as]
|
||||
}
|
||||
_, held := h.applied[as]
|
||||
_, retired := h.r.retired[as]
|
||||
switch {
|
||||
case !read:
|
||||
case asked:
|
||||
if want[as] {
|
||||
return nil // another holder of the same login still binds: nothing to retire
|
||||
case retired, !held && h.r.seeded:
|
||||
}
|
||||
if _, held := h.applied[as]; !held {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s is kept until its retirement is stable (%d passes and %s) and within the bound, or a "+
|
||||
"person approves it", as, h.StablePasses, h.StableFor)
|
||||
if done := h.retire(ctx, []string{as}, "the operator approved its retirement ("+e.Key()+")", nil); len(done) != 1 {
|
||||
return fmt.Errorf("%s was not retired; it is tried again at the next pass", as)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// plan is as much of an assignment as is read to learn who binds the provider.
|
||||
type plan struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Provisioning *struct {
|
||||
Provisions []struct {
|
||||
Provision string `json:"provision"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
// As is who the consumer is at its provider's end, its slug first (issue 788).
|
||||
As string `json:"as"`
|
||||
} `json:"provisions"`
|
||||
} `json:"provisioning"`
|
||||
}
|
||||
|
||||
// asked is the logins the plans bind the provider for, and whether they could all be read: a plan that is not the
|
||||
// controller's, or no read yet, is not an answer, so nothing is counted unasked from it (novox/hq issue 241).
|
||||
// asked is the logins the seat's plans bind the provider for (novox/hq ADR 0570 clause 2), and whether they could all be
|
||||
// read: a plan that cannot be read, or no read yet, is not an answer (novox/hq issue 241).
|
||||
func (m *Moved) asked(node string) (map[string]bool, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.loaded || len(m.assignments) == 0 {
|
||||
if !m.loaded || len(m.plans) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for key, raw := range m.assignments {
|
||||
var a plan
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.Node+"."+a.Module != key {
|
||||
for key, raw := range m.plans {
|
||||
pl, err := provisions.DecodePlan(raw)
|
||||
if err != nil || pl.Node+"."+pl.Module != key {
|
||||
return nil, false
|
||||
}
|
||||
if a.Provisioning == nil {
|
||||
continue
|
||||
}
|
||||
for _, b := range a.Provisioning.Provisions {
|
||||
for _, b := range pl.Plan.Provisions {
|
||||
if b.Provider == node && b.ProviderModule == m.spec.Module && b.Provision == m.spec.Provision {
|
||||
as := b.As
|
||||
if as == "" {
|
||||
as = provider.ConsumerIdentity(a.Node, a.Module) // a plan that names none
|
||||
as = provider.ConsumerIdentity(pl.Node, pl.Module) // a plan that names none
|
||||
}
|
||||
want[as] = true
|
||||
}
|
||||
@@ -223,8 +196,8 @@ func (m *Moved) granted(planned map[string]bool) ([]contribution, map[string]boo
|
||||
return own, logins, true
|
||||
}
|
||||
|
||||
// retire is one pass of the harness's retirement over what the plans bind and the grant file gives now; the logins
|
||||
// only the grant file gives are made and checked first, as the grant path does.
|
||||
// retire is one pass over what the plans bind and the grant file gives now: the logins only the grant file gives are
|
||||
// made and checked, as the grant path does. It retires nothing (ADR 0570 clause 5).
|
||||
func (m *Moved) retire(ctx context.Context) {
|
||||
h := m.h
|
||||
want, ok := m.asked(h.Node)
|
||||
@@ -245,7 +218,7 @@ func (m *Moved) retire(ctx context.Context) {
|
||||
}
|
||||
h.seed(ctx, want)
|
||||
h.apply(ctx, given)
|
||||
h.retireUnasked(ctx, want)
|
||||
// No login is retired for going unasked (novox/hq ADR 0570 clause 5): only Gone, on the operator's yes.
|
||||
h.r.lastWant = want
|
||||
for as := range h.failing {
|
||||
if !want[as] {
|
||||
@@ -261,13 +234,13 @@ func (m *Moved) retire(ctx context.Context) {
|
||||
|
||||
// watch keeps the plans as the bus holds them, from the first read on.
|
||||
func (m *Moved) watch(ctx context.Context, bus provider.Bus) error {
|
||||
err := bus.Watch(ctx, provider.AssignmentsBucket, func(key string, value []byte) {
|
||||
err := bus.Watch(ctx, provisions.PlansBucket, func(key string, value []byte) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if value == nil {
|
||||
delete(m.assignments, key)
|
||||
delete(m.plans, key)
|
||||
} else {
|
||||
m.assignments[key] = append([]byte(nil), value...)
|
||||
m.plans[key] = append([]byte(nil), value...)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -57,6 +59,43 @@ func (b *bucketBus) Delete(_ context.Context, bucket, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// putPlan is a consumer's plan on the seat mesh-provisioner, of these plan entries, each provision's key provided (novox/hq
|
||||
// ADR 0570 clauses 1, 2): what the helper binds by.
|
||||
func (b *bucketBus) putPlan(node, module string, entries ...map[string]any) {
|
||||
raw, _ := json.Marshal(entries)
|
||||
var ps []catalogue.PlanProvision
|
||||
if err := json.Unmarshal(raw, &ps); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, pp := range ps {
|
||||
pr := provisions.Provision{Provision: pp.Provision, Local: pp.Local, Consumer: provisions.End{Module: module, Node: node},
|
||||
Provider: provisions.End{Module: pp.ProviderModule, Node: pp.Provider}, State: provisions.Provided}
|
||||
key, _ := pr.Key()
|
||||
value, err := provisions.Encode(pr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = b.Put(context.Background(), provisions.ProvisionsBucket, key, value)
|
||||
}
|
||||
plan, err := provisions.EncodePlan(provisions.Plan{Node: node, Module: module, Plan: catalogue.Plan{Provisions: ps}})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = b.Put(context.Background(), provisions.PlansBucket, node+"."+module, plan)
|
||||
}
|
||||
|
||||
// retireOn is a consumer's provision retiring with the operator's yes (ADR 0570 clause 5), its plan gone: the one way
|
||||
// a provider lets it go.
|
||||
func (b *bucketBus) retireOn(node, module, provides string) {
|
||||
pr := provisions.Provision{Provision: ProvisionName, Consumer: provisions.End{Module: module, Node: node},
|
||||
Provider: provisions.End{Module: provides, Node: "novox"}, State: provisions.Retiring, Why: module + " left",
|
||||
Warrant: &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}}
|
||||
key, _ := pr.Key()
|
||||
value, _ := provisions.Encode(pr)
|
||||
_ = b.Delete(context.Background(), provisions.PlansBucket, node+"."+module)
|
||||
_ = b.Put(context.Background(), provisions.ProvisionsBucket, key, value)
|
||||
}
|
||||
|
||||
// bind is one consumer's assignment, whose plan names this machine's postgres as the provider and carries what the
|
||||
// consumer contributes (issue 776).
|
||||
func (b *bucketBus) bind(node, module string, contributes map[string]any) {
|
||||
@@ -65,12 +104,8 @@ func (b *bucketBus) bind(node, module string, contributes map[string]any) {
|
||||
|
||||
// bindNamed is bind to a provider module, with the names the controller composed for the consumer (issue 792).
|
||||
func (b *bucketBus) bindNamed(provides, node, module string, contributes map[string]any, names map[string]string) {
|
||||
plan, _ := json.Marshal(map[string]any{"node": node, "module": module, "provisioning": map[string]any{
|
||||
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": provides,
|
||||
"contributes": contributes, "names": names}}}})
|
||||
if err := b.Put(context.Background(), provider.AssignmentsBucket, node+"."+module, plan); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
b.putPlan(node, module, map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": provides,
|
||||
"contributes": contributes, "names": names})
|
||||
}
|
||||
|
||||
// consumersFile writes the file the machine's client fills: one credential per consumer.
|
||||
@@ -124,9 +159,9 @@ func newMovedWorldOf(t *testing.T, bound map[[2]string]string, spec MovedSpec) *
|
||||
func (w *movedWorld) pass() []provider.Outcome {
|
||||
w.now = w.now.Add(5 * time.Second)
|
||||
// the plans as the bus holds them now, as the watch would hand them
|
||||
w.run.assignments = map[string][]byte{}
|
||||
for k, v := range w.bus.buckets[provider.AssignmentsBucket] {
|
||||
w.run.assignments[k] = v
|
||||
w.run.plans = map[string][]byte{}
|
||||
for k, v := range w.bus.buckets[provisions.PlansBucket] {
|
||||
w.run.plans[k] = v
|
||||
}
|
||||
// the helper reads its buckets from the in-memory bus
|
||||
w.p = provider.New(provider.Options{Node: "novox", Module: w.spec.Module, Bus: w.bus,
|
||||
@@ -205,46 +240,27 @@ func TestTheConsumersFileMakesWhatTheGrantFilesMake(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// One read without a consumer retires nothing: it takes the harness's stable passes and time, and then the entry
|
||||
// goes and the login is retired, never dropped.
|
||||
// A consumer whose plan goes retires nothing by itself, however long (novox/hq ADR 0570 clause 5): its login is retired,
|
||||
// never dropped, and its entry removed only once its provision is retiring with the operator's yes.
|
||||
func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) {
|
||||
bound, logins := bindings(2)
|
||||
w := newMovedWorld(t, bound)
|
||||
w.bus.bind("ace", "app1", nil)
|
||||
w.bus.bind("ace", "app2", nil)
|
||||
w.pass()
|
||||
|
||||
// One read without app2.
|
||||
saved := w.bus.buckets[provider.AssignmentsBucket]["ace.app2"]
|
||||
w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2")
|
||||
w.pass()
|
||||
if len(w.a.removed) != 0 {
|
||||
t.Fatalf("retired %v on one read", w.a.removed)
|
||||
}
|
||||
if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; !kept {
|
||||
t.Fatal("the entry went on one read")
|
||||
}
|
||||
// It is back: nothing was lost, and the entry never left the bucket.
|
||||
w.bus.Put(ctx, provider.AssignmentsBucket, "ace.app2", saved)
|
||||
_ = w.bus.Delete(ctx, provisions.PlansBucket, "ace.app2")
|
||||
for i := 0; i < 8; i++ {
|
||||
w.pass()
|
||||
}
|
||||
w.now = w.now.Add(15 * time.Minute)
|
||||
w.now = w.now.Add(30 * time.Minute)
|
||||
w.pass()
|
||||
if len(w.a.removed) != 0 || len(w.bus.buckets[provider.BucketOf("postgres")]) != 2 {
|
||||
t.Fatalf("retired %v, entries %d", w.a.removed, len(w.bus.buckets[provider.BucketOf("postgres")]))
|
||||
if len(w.a.removed) != 0 || w.h.r.waiting != nil {
|
||||
t.Fatalf("retired %v, waiting %v, with no warrant", w.a.removed, w.h.r.waiting)
|
||||
}
|
||||
|
||||
// Gone for good: not retired before StablePasses and StableFor, then retired and its entry removed.
|
||||
w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2")
|
||||
for i := 0; i < 6; i++ {
|
||||
w.pass()
|
||||
if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; !kept {
|
||||
t.Fatal("the entry went with no warrant")
|
||||
}
|
||||
if len(w.a.removed) != 0 {
|
||||
t.Fatalf("retired %v before the time was out", w.a.removed)
|
||||
}
|
||||
w.now = w.now.Add(11 * time.Minute)
|
||||
w.pass()
|
||||
w.bus.retireOn("ace", "app2", "postgres")
|
||||
w.pass()
|
||||
if !reflect.DeepEqual(w.a.removed, []string{logins[1]}) || len(w.a.deleted) != 0 {
|
||||
t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted)
|
||||
@@ -254,35 +270,13 @@ func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// More than three at once waits for a person, as the harness's bound says.
|
||||
func TestMoreThanTheBoundWaitsForAPerson(t *testing.T) {
|
||||
bound, _ := bindings(5)
|
||||
w := newMovedWorld(t, bound)
|
||||
for i := 1; i <= 5; i++ {
|
||||
w.bus.bind("ace", fmt.Sprintf("app%d", i), nil)
|
||||
}
|
||||
w.pass()
|
||||
for i := 1; i <= 4; i++ {
|
||||
w.bus.Delete(ctx, provider.AssignmentsBucket, fmt.Sprintf("ace.app%d", i))
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
w.pass()
|
||||
}
|
||||
w.now = w.now.Add(11 * time.Minute)
|
||||
w.pass()
|
||||
w.pass()
|
||||
if len(w.a.removed) != 0 || w.h.r.waiting == nil {
|
||||
t.Fatalf("retired %v, waiting %v", w.a.removed, w.h.r.waiting)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan list that was never read is not nobody asking.
|
||||
func TestNoPlansReadRetiresNothing(t *testing.T) {
|
||||
bound, _ := bindings(1)
|
||||
w := newMovedWorld(t, bound)
|
||||
w.bus.bind("ace", "app1", nil)
|
||||
w.pass()
|
||||
w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app1")
|
||||
w.bus.Delete(ctx, provisions.PlansBucket, "ace.app1")
|
||||
for i := 0; i < 8; i++ {
|
||||
w.pass()
|
||||
}
|
||||
@@ -310,12 +304,8 @@ func TestAConsumerWithoutACredentialIsWaitedFor(t *testing.T) {
|
||||
func TestASluggedConsumerKeepsTheLoginItsPlanNames(t *testing.T) {
|
||||
agent := provider.ConsumerIdentity("ace", "agent")
|
||||
w := newMovedWorld(t, map[[2]string]string{{"ace", "claude-code"}: "pw-agent"})
|
||||
plan, _ := json.Marshal(map[string]any{"node": "ace", "module": "claude-code", "provisioning": map[string]any{
|
||||
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox",
|
||||
"provider-module": "postgres", "as": agent}}}})
|
||||
if err := w.bus.Put(ctx, provider.AssignmentsBucket, "ace.claude-code", plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.bus.putPlan("ace", "claude-code", map[string]any{"provision": ProvisionName, "provider": "novox",
|
||||
"provider-module": "postgres", "as": agent})
|
||||
for _, o := range w.pass() {
|
||||
if o.Status != provider.Answered {
|
||||
t.Fatalf("%+v", o)
|
||||
@@ -353,10 +343,8 @@ func TestASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile(t *testing
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, _ := json.Marshal(map[string]any{"node": "ace", "module": "wiki", "provisioning": map[string]any{
|
||||
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox",
|
||||
"provider-module": "keycloak", "as": "wiki-client", "contributes": map[string]any{"label": "Wiki"}}}}})
|
||||
_ = bus.Put(ctx, provider.AssignmentsBucket, "ace.wiki", plan)
|
||||
bus.putPlan("ace", "wiki", map[string]any{"provision": ProvisionName, "provider": "novox",
|
||||
"provider-module": "keycloak", "as": "wiki-client", "contributes": map[string]any{"label": "Wiki"}})
|
||||
if err := run.watch(ctx, bus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -454,12 +442,8 @@ func TestTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape(t *testin
|
||||
func TestAConsumerNamedByItsPlanKeepsItsClientId(t *testing.T) {
|
||||
agent := provider.ConsumerIdentity("home", "dashboard")
|
||||
w := newMovedWorldOf(t, map[[2]string]string{{"home", "grafana-2"}: "pw"}, keycloakSpec)
|
||||
plan, _ := json.Marshal(map[string]any{"node": "home", "module": "grafana-2", "provisioning": map[string]any{
|
||||
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak",
|
||||
"as": agent, "contributes": grafanaContributes, "names": grafanaNames}}}})
|
||||
if err := w.bus.Put(ctx, provider.AssignmentsBucket, "home.grafana-2", plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.bus.putPlan("home", "grafana-2", map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak",
|
||||
"as": agent, "contributes": grafanaContributes, "names": grafanaNames})
|
||||
for _, o := range w.pass() {
|
||||
if o.Status != provider.Answered {
|
||||
t.Fatalf("%+v", o)
|
||||
|
||||
+173
-105
@@ -47,6 +47,7 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
// AssignmentsBucket is the controller's assignments mirror, `<node>.<module>`, whose value carries the plan of a
|
||||
@@ -124,6 +125,11 @@ type Entry struct {
|
||||
VaultName string `json:"vault-name,omitempty"`
|
||||
// Refused, when set, is why the consumer is not served, naming both ends.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
// State is the provider's outcome for the consumer, which the holder of mesh-provisioner reads to set the
|
||||
// provision's key (novox/hq ADR 0570 clause 3): provided, failed with Why, or waits with what is awaited. Empty from
|
||||
// a provider before it.
|
||||
State string `json:"state,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
// Contributes is what the consumer's module tells its provider about this provision (a database's name, its
|
||||
// extensions), from its plan's entry (issue 776). Handed to Make and Gone; never written to the bucket.
|
||||
Contributes map[string]any `json:"-"`
|
||||
@@ -185,11 +191,13 @@ type Options struct {
|
||||
type Provider struct {
|
||||
opts Options
|
||||
|
||||
mu sync.Mutex
|
||||
assignments map[string][]byte
|
||||
entries map[string][]byte
|
||||
said map[string]string
|
||||
poke chan struct{}
|
||||
mu sync.Mutex
|
||||
entries map[string][]byte
|
||||
// plans and provisions are the seat mesh-provisioner's states (ADR 0570 clauses 1, 2), by key.
|
||||
plans map[string][]byte
|
||||
provisions map[string][]byte
|
||||
said map[string]string
|
||||
poke chan struct{}
|
||||
}
|
||||
|
||||
// New is a provider answering its consumers as opts say.
|
||||
@@ -203,10 +211,15 @@ func New(opts Options) *Provider {
|
||||
if opts.Logf == nil {
|
||||
opts.Logf = func(f string, a ...any) { fmt.Printf("[provider] "+f+"\n", a...) }
|
||||
}
|
||||
return &Provider{opts: opts, assignments: map[string][]byte{}, entries: map[string][]byte{},
|
||||
said: map[string]string{}, poke: make(chan struct{}, 1)}
|
||||
return &Provider{opts: opts, entries: map[string][]byte{},
|
||||
plans: map[string][]byte{}, provisions: map[string][]byte{}, said: map[string]string{}, poke: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// ErrNotReady is what a Make returns, wrapped or alone, when the provider cannot make a consumer's provision yet but
|
||||
// will without anyone's hand (its keys not read since a restart): the entry waits and keeps serving what it served.
|
||||
// Any other error is a failure, and its entry serves nothing.
|
||||
var ErrNotReady = errors.New("not ready yet")
|
||||
|
||||
// What a pass did for one bind.
|
||||
const (
|
||||
Answered = "answered"
|
||||
@@ -232,16 +245,22 @@ func (p *Provider) Run(ctx context.Context) error {
|
||||
if err := p.watch(ctx, BucketOf(p.opts.Module), p.entries); err != nil {
|
||||
return fmt.Errorf("watching %s: %w", BucketOf(p.opts.Module), err)
|
||||
}
|
||||
if err := p.watch(ctx, AssignmentsBucket, p.assignments); err != nil {
|
||||
return fmt.Errorf("watching %s: %w", AssignmentsBucket, err)
|
||||
for _, bucket := range []string{provisions.PlansBucket, provisions.ProvisionsBucket} {
|
||||
into := p.plans
|
||||
if bucket == provisions.ProvisionsBucket {
|
||||
into = p.provisions
|
||||
}
|
||||
if err := p.watch(ctx, bucket, into); err != nil {
|
||||
return fmt.Errorf("watching %s: %w", bucket, err)
|
||||
}
|
||||
}
|
||||
tick := time.NewTicker(p.opts.Every)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
p.mu.Lock()
|
||||
assignments, entries := clone(p.assignments), clone(p.entries)
|
||||
in := Inputs{Entries: clone(p.entries), Plans: clone(p.plans), Provisions: clone(p.provisions)}
|
||||
p.mu.Unlock()
|
||||
if _, err := p.pass(ctx, assignments, entries); err != nil {
|
||||
if _, err := p.passIn(ctx, in); err != nil {
|
||||
p.say("pass", err.Error())
|
||||
}
|
||||
select {
|
||||
@@ -262,7 +281,7 @@ func (p *Provider) watch(ctx context.Context, bucket string, into map[string][]b
|
||||
into[key] = append([]byte(nil), value...)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if bucket == AssignmentsBucket {
|
||||
if bucket != BucketOf(p.opts.Module) {
|
||||
select {
|
||||
case p.poke <- struct{}{}:
|
||||
default:
|
||||
@@ -302,110 +321,82 @@ func (p *Provider) Pass(ctx context.Context) ([]Outcome, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", BucketOf(p.opts.Module), err)
|
||||
}
|
||||
assignments, err := read(AssignmentsBucket)
|
||||
plans, err := read(provisions.PlansBucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", AssignmentsBucket, err)
|
||||
return nil, fmt.Errorf("reading %s: %w", provisions.PlansBucket, err)
|
||||
}
|
||||
return p.pass(ctx, assignments, entries)
|
||||
provs, err := read(provisions.ProvisionsBucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", provisions.ProvisionsBucket, err)
|
||||
}
|
||||
return p.passIn(ctx, Inputs{Entries: entries, Plans: plans, Provisions: provs})
|
||||
}
|
||||
|
||||
// planned is as much of an assignment as the helper reads: lenient, since the plan is the client's to act on.
|
||||
type planned struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Provisioning *struct {
|
||||
Provisions []struct {
|
||||
Provision string `json:"provision"`
|
||||
Local string `json:"local"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
Contributes map[string]any `json:"contributes"`
|
||||
At string `json:"at"`
|
||||
From string `json:"from"`
|
||||
As string `json:"as"`
|
||||
Names map[string]string `json:"names"`
|
||||
CredentialForm string `json:"credential-form"`
|
||||
Name string `json:"name"`
|
||||
} `json:"provisions"`
|
||||
} `json:"provisioning"`
|
||||
// Inputs are what one pass reads: the provider's own entries, and the seat mesh-provisioner's plans and provisions
|
||||
// (novox/hq ADR 0570 clauses 1, 2), each by key. The controller's assignments are read no more (clause 2).
|
||||
type Inputs struct {
|
||||
Entries, Plans, Provisions map[string][]byte
|
||||
}
|
||||
|
||||
func (p *Provider) pass(ctx context.Context, assignments, entries map[string][]byte) ([]Outcome, error) {
|
||||
func (p *Provider) passIn(ctx context.Context, in Inputs) ([]Outcome, error) {
|
||||
entries := in.Entries
|
||||
var binds []Entry
|
||||
var out []Outcome
|
||||
unknown := map[string]bool{} // consumers whose assignment was skipped: their entries are not judged
|
||||
for _, key := range sortedKeys(assignments) {
|
||||
if !Standing(assignments[key]) {
|
||||
continue // ended or pending: read as a removed key (issue 1098)
|
||||
}
|
||||
var a planned
|
||||
if err := json.Unmarshal(assignments[key], &a); err != nil || a.Node+"."+a.Module != key {
|
||||
o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the assignment %s is not the controller's, so "+
|
||||
"who it binds is not known; it is skipped, and its entries are left as they are", key)}
|
||||
// Every consumer is bound by its plan and its provision keys (ADR 0570 clauses 2, 3), and an entry no plan names is
|
||||
// never let go by the provider itself (clause 5): only a retiring key with the operator's yes retires it.
|
||||
seat := map[string]bool{}
|
||||
gated := map[string]provisions.Provision{}
|
||||
for _, key := range sortedKeys(in.Plans) {
|
||||
plan, err := provisions.DecodePlan(in.Plans[key])
|
||||
if err != nil || plan.Node+"."+plan.Module != key {
|
||||
o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the plan %s is not readable, so who it binds is not "+
|
||||
"known; it is skipped, and its entries are left as they are", key)}
|
||||
out = append(out, o)
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
unknown[key] = true
|
||||
unknown[key], seat[key] = true, true
|
||||
continue
|
||||
}
|
||||
if a.Provisioning == nil {
|
||||
continue
|
||||
}
|
||||
for _, b := range a.Provisioning.Provisions {
|
||||
seat[key] = true
|
||||
for _, b := range plan.Plan.Provisions {
|
||||
if b.Provider != p.opts.Node || b.ProviderModule != p.opts.Module {
|
||||
continue
|
||||
}
|
||||
binds = append(binds, Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module,
|
||||
Provision: b.Provision, Consumer: a.Node, Module: a.Module, Local: b.Local,
|
||||
Contributes: b.Contributes, At: b.At, From: b.From, As: b.As, Names: b.Names,
|
||||
CredentialForm: b.CredentialForm, Name: b.Name})
|
||||
e := Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module, Provision: b.Provision, Consumer: plan.Node,
|
||||
Module: plan.Module, Local: b.Local, Contributes: b.Contributes, At: b.At, From: b.From, As: b.As,
|
||||
Names: b.Names, CredentialForm: b.CredentialForm, Name: b.Name}
|
||||
binds = append(binds, e)
|
||||
}
|
||||
}
|
||||
for _, key := range sortedKeys(in.Provisions) {
|
||||
if pr, err := provisions.Decode(in.Provisions[key]); err == nil && pr.Provider.Node == p.opts.Node &&
|
||||
pr.Provider.Module == p.opts.Module {
|
||||
gated[key] = pr
|
||||
}
|
||||
}
|
||||
|
||||
want := map[string]bool{}
|
||||
for _, b := range binds {
|
||||
key := b.Key()
|
||||
want[key] = true
|
||||
o := p.answer(ctx, b, entries[key])
|
||||
out = append(out, o)
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
}
|
||||
for _, key := range sortedKeys(entries) {
|
||||
if want[key] || unknown[consumerOf(key)] {
|
||||
continue
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(entries[key], &e); err != nil || e.Provider != p.opts.Node ||
|
||||
e.ProviderModule != p.opts.Module {
|
||||
continue // another machine's instance of this provider wrote it, or nothing of this helper's
|
||||
}
|
||||
if p.opts.Gone != nil {
|
||||
if err := p.opts.Gone(ctx, e); err != nil {
|
||||
o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and the provider could not let it go: " +
|
||||
err.Error()}
|
||||
out = append(out, o)
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := p.remove(ctx, key, entries[key]); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
o := Outcome{Key: key, Status: Superseded, Why: "another instance of the provider wrote its entry " +
|
||||
"since this pass read it, so it stands"}
|
||||
out = append(out, o)
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
continue
|
||||
}
|
||||
o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and its entry could not be removed: " +
|
||||
err.Error()}
|
||||
if why, ok := p.ready(key, gated, in.Provisions); !ok {
|
||||
o := Outcome{Key: key, Status: Waiting, Why: why}
|
||||
out = append(out, o)
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
continue
|
||||
}
|
||||
out = append(out, Outcome{Key: key, Status: Removed})
|
||||
p.say(key, Removed)
|
||||
p.mu.Lock()
|
||||
delete(p.said, key)
|
||||
p.mu.Unlock()
|
||||
o := p.answer(ctx, b, entries[key])
|
||||
out = append(out, o)
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
}
|
||||
// ADR 0570 clause 5: a retiring key the operator said yes to is let go; one asked and not answered, or declined,
|
||||
// keeps its entry.
|
||||
for _, key := range sortedKeys(gated) {
|
||||
if pr := gated[key]; pr.State == provisions.Retiring && pr.Warrant != nil && pr.Warrant.Option == "yes" {
|
||||
want[key] = false
|
||||
if _, held := entries[key]; held {
|
||||
out = append(out, p.letGo(ctx, key, entries[key])...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -443,6 +434,7 @@ func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome {
|
||||
strings.Join(derivedKeys(offer.Serves), ", "))
|
||||
}
|
||||
if e.Refused != "" {
|
||||
e.State, e.Why = provisions.Failed, e.Refused
|
||||
return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused})
|
||||
}
|
||||
|
||||
@@ -455,6 +447,7 @@ func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome {
|
||||
e.Refused = fmt.Sprintf("%s on %s serving %s to %s on %s: %v", e.ProviderModule, e.Provider, e.Provision,
|
||||
e.Module, e.Consumer, err)
|
||||
e.As = ""
|
||||
e.State, e.Why = provisions.Failed, e.Refused
|
||||
return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused})
|
||||
}
|
||||
if e.At != "" || e.From != "" {
|
||||
@@ -505,12 +498,31 @@ func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome {
|
||||
}
|
||||
}
|
||||
if p.opts.Make != nil {
|
||||
if err := p.opts.Make(ctx, Consumer{Entry: e, Credential: credential}); err != nil {
|
||||
why := err.Error()
|
||||
why = redact(why, credential)
|
||||
return Outcome{Key: key, Status: Failed, Why: "the provider could not make it: " + why}
|
||||
if err := p.opts.Make(ctx, Consumer{Entry: e, Credential: credential}); errors.Is(err, ErrNotReady) {
|
||||
// **Not ready is no failure** (the regression of 2026-10-30: gitea after a restart, its keys not read yet,
|
||||
// stopped serving every consumer): the entry says waits with why, and keeps what the last entry served, so a
|
||||
// consumer goes on with it until the provider can make it again.
|
||||
why := "the provider is not ready to make it yet: " + redact(err.Error(), credential)
|
||||
waiting := e
|
||||
var prev Entry
|
||||
if len(have) > 0 && json.Unmarshal(have, &prev) == nil && prev.State == provisions.Provided {
|
||||
waiting.Values, waiting.VaultName, waiting.User = prev.Values, prev.VaultName, prev.User
|
||||
} else {
|
||||
waiting.Values, waiting.VaultName, waiting.User = nil, "", ""
|
||||
}
|
||||
waiting.State, waiting.Why = provisions.Waits, why
|
||||
p.write(ctx, waiting, have, Outcome{Key: key, Status: Waiting, Why: why})
|
||||
return Outcome{Key: key, Status: Waiting, Why: why}
|
||||
} else if err != nil {
|
||||
why := "the provider could not make it: " + redact(err.Error(), credential)
|
||||
// Said in its entry, which the holder of mesh-provisioner reads (ADR 0570 clause 3), with nothing served.
|
||||
failed := e
|
||||
failed.Values, failed.VaultName, failed.User, failed.State, failed.Why = nil, "", "", provisions.Failed, why
|
||||
p.write(ctx, failed, have, Outcome{Key: key, Status: Failed, Why: why})
|
||||
return Outcome{Key: key, Status: Failed, Why: why}
|
||||
}
|
||||
}
|
||||
e.State = provisions.Provided
|
||||
return p.write(ctx, e, have, Outcome{Key: key, Status: Answered})
|
||||
}
|
||||
|
||||
@@ -557,20 +569,20 @@ func (p *Provider) say(key, what string) {
|
||||
}
|
||||
|
||||
// SharedName is the vault's one value per provider and provision (ADR 0158, issue 677).
|
||||
func SharedName(provider, provision string) string { return "provision/" + provider + "/" + provision }
|
||||
func SharedName(provider, provision string) string {
|
||||
return provisions.VaultName(provisions.FormShared, provisions.Provision{Provision: provision, Provider: provisions.End{Node: provider}})
|
||||
}
|
||||
|
||||
// HolderName is the vault's value of one holder's own credential, issued or given for it (mesh-vault perholder.go).
|
||||
func HolderName(provider, provision, node, module string) string {
|
||||
return "provision/" + provider + "/" + provision + "/" + node + "/" + module
|
||||
return provisions.VaultName(provisions.FormIssued, provisions.Provision{Provision: provision,
|
||||
Provider: provisions.End{Node: provider}, Consumer: provisions.End{Node: node, Module: module}})
|
||||
}
|
||||
|
||||
// PairName is the vault's value of one consumer's own login (ADR 0048, ADR 0158 §11, issue 677's note 12).
|
||||
func PairName(provider, provision, node, module, local string) string {
|
||||
n := "pair/" + provider + "/" + provision + "/" + node + "/" + module
|
||||
if local != "" {
|
||||
n += "/" + local
|
||||
}
|
||||
return n
|
||||
return provisions.VaultName(provisions.FormPair, provisions.Provision{Provision: provision, Local: local,
|
||||
Provider: provisions.End{Node: provider}, Consumer: provisions.End{Node: node, Module: module}})
|
||||
}
|
||||
|
||||
// identityUnusable is every character a consumer's identity does not hold (mesh-controller internal/catalogue
|
||||
@@ -672,8 +684,9 @@ func sortedKeys[V any](m map[string]V) []string {
|
||||
}
|
||||
|
||||
// StateBus is the Bus of a provider that runs as a module bundle over the runtime's stdio: the provider's own bucket,
|
||||
// BucketOf(module), is the module's state `bound`, and AssignmentsBucket is the state it reads,
|
||||
// `mesh-controller.assignments` (both declared in the module's manifest). Any other bucket is refused.
|
||||
// BucketOf(module), is the module's state `bound`, and provisions.PlansBucket and provisions.ProvisionsBucket are the
|
||||
// states it reads, `mesh-provisioner.plans` and `mesh-provisioner.provisions` (each declared in the module's manifest).
|
||||
// Any other bucket is refused.
|
||||
//
|
||||
// A stdio state watch lasts as long as the process and cannot be ended (stdio.KeptState.Watch), so Watch honours the
|
||||
// Bus contract by silencing instead: once ctx has ended the handler is never called again, though the runtime keeps
|
||||
@@ -687,8 +700,10 @@ func (b stateBus) state(bucket string) (stdio.KeptState, error) {
|
||||
switch bucket {
|
||||
case BucketOf(b.module):
|
||||
return stdio.State("bound"), nil
|
||||
case AssignmentsBucket:
|
||||
return stdio.State("mesh-controller.assignments"), nil
|
||||
case provisions.PlansBucket:
|
||||
return stdio.State(provisions.PlansRead), nil
|
||||
case provisions.ProvisionsBucket:
|
||||
return stdio.State(provisions.ProvisionsRead), nil
|
||||
}
|
||||
return stdio.KeptState{}, fmt.Errorf("%s is not a bucket this provider keeps or reads", bucket)
|
||||
}
|
||||
@@ -767,3 +782,56 @@ func fieldSet(f json.RawMessage) bool {
|
||||
s := strings.TrimSpace(string(f))
|
||||
return s != "" && s != "null" && s != `""`
|
||||
}
|
||||
|
||||
// letGo lets a consumer's entry go: the provider hears it gone (Gone, which retires what it kept for the consumer), and
|
||||
// the entry is removed while it is still as read.
|
||||
func (p *Provider) letGo(ctx context.Context, key string, held []byte) []Outcome {
|
||||
var e Entry
|
||||
if err := json.Unmarshal(held, &e); err != nil || e.Provider != p.opts.Node || e.ProviderModule != p.opts.Module {
|
||||
return nil // another machine's instance of this provider wrote it, or nothing of this helper's
|
||||
}
|
||||
said := func(o Outcome) []Outcome {
|
||||
p.say(key, o.Status+" "+o.Why)
|
||||
return []Outcome{o}
|
||||
}
|
||||
if p.opts.Gone != nil {
|
||||
if err := p.opts.Gone(ctx, e); err != nil {
|
||||
return said(Outcome{Key: key, Status: Failed, Why: "no longer bound, and the provider could not let it go: " + err.Error()})
|
||||
}
|
||||
}
|
||||
if err := p.remove(ctx, key, held); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return said(Outcome{Key: key, Status: Superseded, Why: "another instance of the provider wrote its entry " +
|
||||
"since this pass read it, so it stands"})
|
||||
}
|
||||
return said(Outcome{Key: key, Status: Failed, Why: "no longer bound, and its entry could not be removed: " + err.Error()})
|
||||
}
|
||||
p.say(key, Removed)
|
||||
p.mu.Lock()
|
||||
delete(p.said, key)
|
||||
p.mu.Unlock()
|
||||
return []Outcome{{Key: key, Status: Removed}}
|
||||
}
|
||||
|
||||
// ready says whether the provision under key may be made now (novox/hq ADR 0570 clause 3): its key is on the seat, it is
|
||||
// asked or provided, and every provision it depends on is provided. Otherwise why it waits, naming the key or the
|
||||
// dependency.
|
||||
func (p *Provider) ready(key string, mine map[string]provisions.Provision, all map[string][]byte) (string, bool) {
|
||||
pr, ok := mine[key]
|
||||
if !ok {
|
||||
return "mesh-provisioner holds no key " + key + " naming this provider yet", false
|
||||
}
|
||||
if pr.State != provisions.Asked && pr.State != provisions.Provided {
|
||||
return "its key is " + pr.State + ": " + pr.Why, false
|
||||
}
|
||||
for _, d := range pr.DependsOn {
|
||||
dep, err := provisions.Decode(all[d])
|
||||
if err != nil {
|
||||
return "it depends on " + d + ", which mesh-provisioner holds no readable key of", false
|
||||
}
|
||||
if dep.State != provisions.Provided {
|
||||
return "it depends on " + d + ", which is " + dep.State, false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
+200
-46
@@ -15,6 +15,8 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
// standIn is a stand-in bus: buckets of keys and values, and watches that hand the current values first and then
|
||||
@@ -165,22 +167,63 @@ func (m *maker) Gone(_ context.Context, e Entry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// assign puts a consumer's assignment with its plan's provisions into the stand-in mirror.
|
||||
func assign(t *testing.T, bus *standIn, node, module string, provisions ...map[string]string) {
|
||||
// assign puts a consumer's plan with its provisions on the seat mesh-provisioner, each provision's key provided, as its
|
||||
// holder writes them (novox/hq ADR 0570 clauses 1, 2): what a provider binds by.
|
||||
func assign(t *testing.T, bus *standIn, node, module string, binds ...map[string]string) {
|
||||
t.Helper()
|
||||
ps := []map[string]string{}
|
||||
ps = append(ps, provisions...)
|
||||
raw, err := json.Marshal(map[string]any{"node": node, "module": module, "assigned": "2026-10-15T00:00:00Z",
|
||||
"provisioning": map[string]any{"templates": map[string]string{"source": "x", "digest": "y"},
|
||||
"provisions": ps}})
|
||||
var ps []catalogue.PlanProvision
|
||||
for _, b := range binds {
|
||||
raw, _ := json.Marshal(b)
|
||||
var pp catalogue.PlanProvision
|
||||
if err := json.Unmarshal(raw, &pp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps = append(ps, pp)
|
||||
}
|
||||
planned(t, bus, node, module, ps...)
|
||||
}
|
||||
|
||||
// planned puts a consumer's plan of these provisions on the seat, each provision's key provided.
|
||||
func planned(t *testing.T, bus *standIn, node, module string, ps ...catalogue.PlanProvision) {
|
||||
t.Helper()
|
||||
for _, pp := range ps {
|
||||
pr := provisions.Provision{Provision: pp.Provision, Local: pp.Local, Consumer: provisions.End{Module: module, Node: node},
|
||||
Provider: provisions.End{Module: pp.ProviderModule, Node: pp.Provider}, State: provisions.Provided}
|
||||
key, err := pr.Key()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := provisions.Encode(pr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = bus.Put(context.Background(), provisions.ProvisionsBucket, key, value)
|
||||
}
|
||||
raw, err := provisions.EncodePlan(provisions.Plan{Node: node, Module: module, Plan: catalogue.Plan{Provisions: ps}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bus.Put(context.Background(), AssignmentsBucket, node+"."+module, raw); err != nil {
|
||||
if err := bus.Put(context.Background(), provisions.PlansBucket, node+"."+module, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// retire marks a consumer's provision retiring, with the operator's yes (novox/hq ADR 0570 clause 5): the one way a
|
||||
// provider lets a consumer go.
|
||||
func retire(t *testing.T, bus *standIn, node, module, provision, provider, providerModule string) {
|
||||
t.Helper()
|
||||
pr := provisions.Provision{Provision: provision, Consumer: provisions.End{Module: module, Node: node},
|
||||
Provider: provisions.End{Module: providerModule, Node: provider}, State: provisions.Retiring, Why: module + " left " + node,
|
||||
Warrant: &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}}
|
||||
key, _ := pr.Key()
|
||||
raw, err := provisions.Encode(pr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = bus.Delete(context.Background(), provisions.PlansBucket, node+"."+module)
|
||||
_ = bus.Put(context.Background(), provisions.ProvisionsBucket, key, raw)
|
||||
}
|
||||
|
||||
func bind(provision, local, provider, providerModule string) map[string]string {
|
||||
b := map[string]string{"provision": provision, "provider": provider, "provider-module": providerModule}
|
||||
if local != "" {
|
||||
@@ -352,7 +395,7 @@ func TestAnUnbindRemovesTheEntryAndLeavesAnotherProvidersAlone(t *testing.T) {
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta")
|
||||
retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres")
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -418,11 +461,11 @@ func TestOnlyBindsNamingThisProviderAreAnswered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An assignment that cannot be read is skipped and said, by its key and why; the rest of the pass goes on (issue 825,
|
||||
// A plan that cannot be read is skipped and said, by its key and why; the rest of the pass goes on (issue 825,
|
||||
// finding 3). Its consumer's own entry is left alone, since a bind missed would read as an unbind.
|
||||
func TestAnUnreadableAssignmentIsSkippedAndSaidAndTheRestGoesOn(t *testing.T) {
|
||||
for name, bad := range map[string]string{"not json": "{not json",
|
||||
"another key": `{"node":"g14","module":"umami"}`} {
|
||||
"another key": `{"v":1,"node":"g14","module":"umami","plan":{"templates":{"source":"","digest":""},"files":null}}`} {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s",
|
||||
"pair/novox/postgres-database/g14/umami": "u"}}
|
||||
@@ -431,7 +474,7 @@ func TestAnUnreadableAssignmentIsSkippedAndSaidAndTheRestGoesOn(t *testing.T) {
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = bus.Put(context.Background(), AssignmentsBucket, "ace.letta", []byte(bad))
|
||||
_ = bus.Put(context.Background(), provisions.PlansBucket, "ace.letta", []byte(bad))
|
||||
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
|
||||
var logged []string
|
||||
p.opts.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
|
||||
@@ -457,7 +500,8 @@ func TestAnUnreadableAssignmentIsSkippedAndSaidAndTheRestGoesOn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An adapter that fails is said with the credential taken out of its words, and the entry is not written.
|
||||
// An adapter that fails is said with the credential taken out of its words, and its entry says it failed, serving
|
||||
// nothing (novox/hq ADR 0570 clause 3: the provider's outcome is its entry, which the holder of mesh-provisioner reads).
|
||||
func TestAFailedMakeIsSaidWithoutTheCredential(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{fail: errors.New(`password "hunter2-secret" rejected`)}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "hunter2-secret"}}
|
||||
@@ -475,8 +519,38 @@ func TestAFailedMakeIsSaidWithoutTheCredential(t *testing.T) {
|
||||
if strings.Contains(strings.Join(logged, "\n"), "hunter2") || len(logged) == 0 {
|
||||
t.Fatalf("logged %q", logged)
|
||||
}
|
||||
if bus.keys(BucketOf("postgres")) != nil {
|
||||
t.Fatal("an entry was written for a consumer the adapter could not make")
|
||||
var e Entry
|
||||
raw := bus.get(BucketOf("postgres"), "ace.letta.postgres-database")
|
||||
if err := json.Unmarshal(raw, &e); err != nil || e.State != "failed" || e.Values != nil || e.VaultName != "" ||
|
||||
strings.Contains(string(raw), "hunter2") {
|
||||
t.Fatalf("the failed entry is %s", raw)
|
||||
}
|
||||
|
||||
// A provider not ready yet (gitea after a restart, its keys not read; the regression of 2026-10-30) is no failure:
|
||||
// a first entry waits serving nothing, and once made, a later not ready keeps serving what it served.
|
||||
m.fail = fmt.Errorf("keys not read yet: %w", ErrNotReady)
|
||||
_ = bus.Delete(context.Background(), BucketOf("postgres"), "ace.letta.postgres-database")
|
||||
if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Waiting {
|
||||
t.Fatalf("not ready at first: %+v", out)
|
||||
}
|
||||
raw = bus.get(BucketOf("postgres"), "ace.letta.postgres-database")
|
||||
if err := json.Unmarshal(raw, &e); err != nil || e.State != "waits" || !strings.Contains(e.Why, "keys not read yet") || e.Values != nil {
|
||||
t.Fatalf("the first entry not ready is %s", raw)
|
||||
}
|
||||
m.fail = nil
|
||||
if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Answered {
|
||||
t.Fatalf("made: %+v", out)
|
||||
}
|
||||
var made Entry
|
||||
_ = json.Unmarshal(bus.get(BucketOf("postgres"), "ace.letta.postgres-database"), &made)
|
||||
m.fail = ErrNotReady
|
||||
if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Waiting {
|
||||
t.Fatalf("not ready after a restart: %+v", out)
|
||||
}
|
||||
raw = bus.get(BucketOf("postgres"), "ace.letta.postgres-database")
|
||||
if err := json.Unmarshal(raw, &e); err != nil || e.State != "waits" || e.VaultName != made.VaultName || e.User != made.User ||
|
||||
fmt.Sprint(e.Values) != fmt.Sprint(made.Values) || len(made.Values) == 0 {
|
||||
t.Fatalf("not ready after it served, the entry is %s, it served %+v", raw, made)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +566,7 @@ func TestRunAnswersBindsAndUnbindsAsTheyAreWatched(t *testing.T) {
|
||||
go func() { done <- p.Run(ctx) }()
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") != nil })
|
||||
_ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta")
|
||||
retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres")
|
||||
waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil })
|
||||
cancel()
|
||||
if err := <-done; err != nil && !errors.Is(err, context.Canceled) {
|
||||
@@ -532,12 +606,12 @@ func TestAConsumersContributionIsHandedToTheProviderPerConsumer(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{
|
||||
"pair/novox/postgres-database/ace/nextcloud": "a", "pair/novox/postgres-database/g14/umami": "b"}}
|
||||
raw := `{"node":"ace","module":"nextcloud","provisioning":{"templates":{"source":"x","digest":"y"},` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
|
||||
`"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}]}}`
|
||||
if err := bus.Put(context.Background(), AssignmentsBucket, "ace.nextcloud", []byte(raw)); err != nil {
|
||||
var pp catalogue.PlanProvision
|
||||
if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+
|
||||
`"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}`), &pp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planned(t, bus, "ace", "nextcloud", pp)
|
||||
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
|
||||
if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -561,12 +635,12 @@ func TestAConsumersEntryCarriesAtFromAndTheAsOfItsPlan(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{
|
||||
"pair/novox/postgres-database/ace/claude-code": "a", "pair/novox/postgres-database/g14/umami": "b"}}
|
||||
raw := `{"node":"ace","module":"claude-code","provisioning":{"templates":{"source":"x","digest":"y"},` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
|
||||
`"at":"10.7.0.1","from":"novox","as":"mesh_ace_agent"}]}}`
|
||||
if err := bus.Put(context.Background(), AssignmentsBucket, "ace.claude-code", []byte(raw)); err != nil {
|
||||
var pp catalogue.PlanProvision
|
||||
if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+
|
||||
`"at":"10.7.0.1","from":"novox","as":"mesh_ace_agent"}`), &pp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planned(t, bus, "ace", "claude-code", pp)
|
||||
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
|
||||
if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -596,12 +670,12 @@ func TestAConsumersEntryCarriesTheNamesOfItsPlan(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{
|
||||
"pair/novox/postgres-database/ace/gitea": "a", "pair/novox/postgres-database/g14/umami": "b"}}
|
||||
raw := `{"node":"ace","module":"gitea","provisioning":{"templates":{"source":"x","digest":"y"},` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
|
||||
`"names":{"name":"git.example.org","internal-name":"git.mesh","name-web":"web.example.org"}}]}}`
|
||||
if err := bus.Put(context.Background(), AssignmentsBucket, "ace.gitea", []byte(raw)); err != nil {
|
||||
var pp catalogue.PlanProvision
|
||||
if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+
|
||||
`"names":{"name":"git.example.org","internal-name":"git.mesh","name-web":"web.example.org"}}`), &pp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planned(t, bus, "ace", "gitea", pp)
|
||||
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
|
||||
if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -719,6 +793,26 @@ func TestThePlansCredentialFormWinsOverTheOffers(t *testing.T) {
|
||||
if e := run(Shared, nil); e.VaultName != "provision/novox/postgres-database" {
|
||||
t.Fatalf("the offer's form is the default: %+v", e)
|
||||
}
|
||||
{
|
||||
// An offer that declares no credential is answered with none whatever form the plan says (the controller writes
|
||||
// "pair" for such an offer): no vault name, and the Vault is never asked, so a provider with no Vault works (issue 825,
|
||||
// finding 4; issue 923).
|
||||
bus := newStandIn()
|
||||
b := bind("route", "", "novox", "route-proxy")
|
||||
b["credential-form"] = "pair"
|
||||
assign(t, bus, "ace", "letta", b)
|
||||
p := New(Options{Node: "novox", Module: "route-proxy", Bus: bus, Logf: func(string, ...any) {},
|
||||
Offers: map[string]Offer{"route": {Credential: NoCredential}}})
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil || len(out) != 1 || out[0].Status != Answered {
|
||||
t.Fatalf("%v %+v", err, out)
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(bus.get(BucketOf("route-proxy"), "ace.letta.route"), &e); err != nil || e.VaultName != "" ||
|
||||
e.User != "" {
|
||||
t.Fatalf("%v %+v", err, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An instance deletes only an entry it wrote, and only while the entry is as it read it: one that another instance of
|
||||
@@ -733,7 +827,7 @@ func TestAnEntryAnotherInstanceWroteAfterTheMoveStands(t *testing.T) {
|
||||
}
|
||||
// The consumer moves to the instance on shanks: A's pass reads its own entry and the unbind, and while it lets
|
||||
// the consumer go, B writes the consumer's entry.
|
||||
_ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta")
|
||||
retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres")
|
||||
theirs, _ := json.Marshal(Entry{Provider: "shanks", ProviderModule: "postgres", Provision: "postgres-database",
|
||||
Consumer: "ace", Module: "letta", VaultName: "pair/shanks/postgres-database/ace/letta"})
|
||||
a.opts.Gone = func(ctx context.Context, e Entry) error {
|
||||
@@ -781,23 +875,83 @@ func TestAFailedMakeIsSaidWithoutTheCredentialInAnyOfItsForms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An offer that declares no credential is answered with none whatever form the plan says (the controller writes
|
||||
// "pair" for such an offer): no vault name, and the Vault is never asked, so a provider with no Vault works (issue 825,
|
||||
// finding 4; issue 923).
|
||||
func TestAnOfferWithNoCredentialIgnoresThePlansForm(t *testing.T) {
|
||||
bus := newStandIn()
|
||||
b := bind("route", "", "novox", "route-proxy")
|
||||
b["credential-form"] = "pair"
|
||||
assign(t, bus, "ace", "letta", b)
|
||||
p := New(Options{Node: "novox", Module: "route-proxy", Bus: bus, Logf: func(string, ...any) {},
|
||||
Offers: map[string]Offer{"route": {Credential: NoCredential}}})
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil || len(out) != 1 || out[0].Status != Answered {
|
||||
t.Fatalf("%v %+v", err, out)
|
||||
// novox/hq ADR 0570 clauses 3 and 5 (issue 1954 L6-P3, P4): a consumer the seat mesh-provisioner plans is bound by its
|
||||
// plan and its provision key, never by its assignment. Its entry is made only once its key is asked and every provision
|
||||
// it depends on is provided, and says provided; a plan gone retires nothing by itself; a retiring key is let go only on
|
||||
// the operator's yes.
|
||||
func TestTheSeatsKeysSayWhenAProviderActsAndWhenItLetsGo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}}
|
||||
// The same consumer's assignment names another provider: the seat's plan wins.
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "shanks", "postgres"))
|
||||
plan, _ := provisions.EncodePlan(provisions.Plan{Node: "ace", Module: "letta", Plan: catalogue.Plan{
|
||||
Provisions: []catalogue.PlanProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"}}}})
|
||||
_ = bus.Put(ctx, provisions.PlansBucket, "ace.letta", plan)
|
||||
key := "ace.letta.postgres-database"
|
||||
put := func(k string, state string, depends []string, warrant *provisions.Warrant) {
|
||||
pr := provisions.Provision{Provision: "postgres-database", Consumer: provisions.End{Module: "letta", Node: "ace"},
|
||||
Provider: provisions.End{Module: "postgres", Node: "novox"}, DependsOn: depends, State: state, Warrant: warrant}
|
||||
if k != key {
|
||||
_, _, pr.Provision, _, _ = provisions.ParseKey(k)
|
||||
pr.Provider = provisions.End{Module: "mesh-vault", Node: "novox"}
|
||||
}
|
||||
if state != provisions.Asked && state != provisions.Provided {
|
||||
pr.Why = "said"
|
||||
}
|
||||
raw, err := provisions.Encode(pr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = bus.Put(ctx, provisions.ProvisionsBucket, k, raw)
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(bus.get(BucketOf("route-proxy"), "ace.letta.route"), &e); err != nil || e.VaultName != "" ||
|
||||
e.User != "" {
|
||||
t.Fatalf("%v %+v", err, e)
|
||||
p := postgres(bus, v, m)
|
||||
steps := []struct {
|
||||
name string
|
||||
before func()
|
||||
status string
|
||||
says string
|
||||
entry bool
|
||||
}{
|
||||
{name: "no key yet", status: Waiting, says: "holds no key"},
|
||||
{name: "a dependency asked", before: func() {
|
||||
put("ace.letta.secret", provisions.Asked, nil, nil)
|
||||
put(key, provisions.Asked, []string{"ace.letta.secret"}, nil)
|
||||
}, status: Waiting, says: "depends on ace.letta.secret, which is asked"},
|
||||
{name: "the dependency provided", before: func() { put("ace.letta.secret", provisions.Provided, nil, nil) },
|
||||
status: Answered, entry: true},
|
||||
{name: "its plan gone: nothing let go by itself", before: func() { _ = bus.Delete(ctx, provisions.PlansBucket, "ace.letta") },
|
||||
entry: true},
|
||||
{name: "retiring, not yet answered", before: func() { put(key, provisions.Retiring, nil, nil) }, entry: true},
|
||||
{name: "retiring, the operator's yes", before: func() {
|
||||
put(key, provisions.Retiring, nil, &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"})
|
||||
}, status: Removed},
|
||||
}
|
||||
for _, s := range steps {
|
||||
if s.before != nil {
|
||||
s.before()
|
||||
}
|
||||
out, err := p.Pass(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", s.name, err)
|
||||
}
|
||||
var mine []Outcome
|
||||
for _, o := range out {
|
||||
if o.Key == key {
|
||||
mine = append(mine, o)
|
||||
}
|
||||
}
|
||||
if s.status != "" && (len(mine) != 1 || mine[0].Status != s.status || !strings.Contains(mine[0].Why, s.says)) {
|
||||
t.Errorf("%s: %+v", s.name, mine)
|
||||
}
|
||||
if held := bus.get(BucketOf("postgres"), key) != nil; held != s.entry {
|
||||
t.Errorf("%s: an entry held %v", s.name, held)
|
||||
}
|
||||
if s.entry && entryOf(t, bus, key).State != provisions.Provided {
|
||||
t.Errorf("%s: the entry does not say provided", s.name)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(m.gone, []string{key}) || m.made[key] != "s1" {
|
||||
t.Errorf("gone %v, made %v: let go once, on the warrant, and made from the vault's value", m.gone, m.made)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
// runtime drives a bundle over pipes as the runtime does: it reads what the bundle says and sends what the runtime would.
|
||||
@@ -55,8 +56,9 @@ func serve(t *testing.T, tools []stdio.Tool) *runtime {
|
||||
return &runtime{t: t, lines: bufio.NewScanner(outR), in: inW}
|
||||
}
|
||||
|
||||
// The bus of a moved provider maps its own bucket to the state `bound` and the assignments to the state
|
||||
// `mesh-controller.assignments`, writes only the own bucket's state, and refuses any other bucket.
|
||||
// The bus of a moved provider maps its own bucket to the state `bound` and the seat's plans to the state
|
||||
// `mesh-provisioner.plans` (novox/hq ADR 0570 clause 2), writes only the own bucket's state, and refuses any other
|
||||
// bucket, the controller's assignments among them.
|
||||
func TestStateBusMapsTheProvidersBucketsToTheRuntimesStates(t *testing.T) {
|
||||
bus := StateBus("postgres")
|
||||
rt := serve(t, []stdio.Tool{
|
||||
@@ -72,7 +74,7 @@ func TestStateBusMapsTheProvidersBucketsToTheRuntimesStates(t *testing.T) {
|
||||
})
|
||||
for i, c := range []struct{ bucket, state string }{
|
||||
{BucketOf("postgres"), "bound"},
|
||||
{AssignmentsBucket, "mesh-controller.assignments"},
|
||||
{provisions.PlansBucket, "mesh-provisioner.plans"},
|
||||
} {
|
||||
rt.call(i+1, "watch", map[string]any{"bucket": c.bucket})
|
||||
w := rt.next()
|
||||
@@ -97,7 +99,7 @@ func TestStateBusMapsTheProvidersBucketsToTheRuntimesStates(t *testing.T) {
|
||||
rt.send(map[string]any{"jsonrpc": "2.0", "id": d["id"], "result": map[string]any{}})
|
||||
rt.next()
|
||||
|
||||
for _, bucket := range []string{"other_bound", "elsewhere"} {
|
||||
for _, bucket := range []string{"other_bound", "elsewhere", AssignmentsBucket} {
|
||||
if err := bus.Watch(context.Background(), bucket, func(string, []byte) {}); err == nil || !strings.Contains(err.Error(), "not a bucket") {
|
||||
t.Fatalf("a watch of %s gave %v", bucket, err)
|
||||
}
|
||||
@@ -118,7 +120,7 @@ func TestStateBusWatchCallsItsHandlerNoMoreOnceItsContextEnded(t *testing.T) {
|
||||
handled := make(chan string, 8)
|
||||
rt := serve(t, []stdio.Tool{
|
||||
{Name: "start", Run: func(map[string]any) (any, error) {
|
||||
return "started", bus.Watch(ctx, AssignmentsBucket, func(key string, value []byte) {
|
||||
return "started", bus.Watch(ctx, provisions.PlansBucket, func(key string, value []byte) {
|
||||
handled <- key + "=" + string(value)
|
||||
})
|
||||
}},
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// Package provisions is the contract of the seat mesh-provisioner's records (novox/hq ADR 0570 clauses 1, 2 and 5; issue
|
||||
// 1954): every provision one key of the state `provisions`, holding a reference and never a value, and every handed
|
||||
// module's provisioning plan one key of the state `plans`. The module mesh-provisioner alone writes both; the vault,
|
||||
// each node's provisioner client and every provider read them through `reads`.
|
||||
package provisions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The seat, its holder and its two states, and the names a reader declares in its `reads`.
|
||||
const (
|
||||
Seat = "mesh-provisioner"
|
||||
Module = "mesh-provisioner"
|
||||
ProvisionsState = "provisions"
|
||||
PlansState = "plans"
|
||||
ProvisionsRead = Module + "." + ProvisionsState
|
||||
PlansRead = Module + "." + PlansState
|
||||
// ProvisionsBucket and PlansBucket are the states' buckets.
|
||||
ProvisionsBucket = Module + "_" + ProvisionsState
|
||||
PlansBucket = Module + "_" + PlansState
|
||||
)
|
||||
|
||||
// The states of a provision (clause 1).
|
||||
const (
|
||||
// Asked is a provision its consumer needs that its provider has not answered yet, or whose dependencies are not
|
||||
// all provided (clause 3).
|
||||
Asked = "asked"
|
||||
// Provided is a provision the provider said it made: set only from the provider's outcome (clause 3).
|
||||
Provided = "provided"
|
||||
// Waits is a provision only a person can give, naming what is awaited (clause 7).
|
||||
Waits = "waits"
|
||||
// Failed is a provision the provider could not make, with why.
|
||||
Failed = "failed"
|
||||
// Retiring is a provision no want needs, asked of the operator before its credential goes (clause 5).
|
||||
Retiring = "retiring"
|
||||
)
|
||||
|
||||
// States is every state a provision may be in.
|
||||
var States = []string{Asked, Provided, Waits, Failed, Retiring}
|
||||
|
||||
// Version is the version of the records' layout.
|
||||
const Version = 1
|
||||
|
||||
// token is one part of a key: what a bucket's key takes, no dot.
|
||||
var token = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// Key is a provision's key: `<node>.<consumer>.<provision>`, and `.<local>` where the consumer keeps several values of
|
||||
// one provision (ADR 0094). The same key the provider's own entry for that consumer has (provider.Entry.Key).
|
||||
func Key(node, consumer, provision, local string) (string, error) {
|
||||
parts := []string{node, consumer, provision}
|
||||
if local != "" {
|
||||
parts = append(parts, local)
|
||||
}
|
||||
for _, p := range parts {
|
||||
if !token.MatchString(p) {
|
||||
return "", fmt.Errorf("provisions: %q cannot be part of a provision's key", p)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "."), nil
|
||||
}
|
||||
|
||||
// ParseKey is the inverse of Key.
|
||||
func ParseKey(key string) (node, consumer, provision, local string, err error) {
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) < 3 || len(parts) > 4 {
|
||||
return "", "", "", "", fmt.Errorf("provisions: %q is not <node>.<consumer>.<provision>[.<local>]", key)
|
||||
}
|
||||
for _, p := range parts {
|
||||
if !token.MatchString(p) {
|
||||
return "", "", "", "", fmt.Errorf("provisions: %q is not <node>.<consumer>.<provision>[.<local>]", key)
|
||||
}
|
||||
}
|
||||
if len(parts) == 4 {
|
||||
local = parts[3]
|
||||
}
|
||||
return parts[0], parts[1], parts[2], local, nil
|
||||
}
|
||||
|
||||
// PlanKey is a plan's key in `plans`: `<node>.<module>` (clause 2).
|
||||
func PlanKey(node, module string) (string, error) {
|
||||
if !token.MatchString(node) || !token.MatchString(module) {
|
||||
return "", fmt.Errorf("provisions: %s.%s is not a plan's key, <node>.<module>", node, module)
|
||||
}
|
||||
return node + "." + module, nil
|
||||
}
|
||||
|
||||
// The forms of a provision's credential, as a plan's entry names them (catalogue.PlanProvision.CredentialForm).
|
||||
const (
|
||||
FormPair = "pair"
|
||||
FormShared = "shared"
|
||||
FormIssued = "issued"
|
||||
FormGiven = "given"
|
||||
// FormNone is an offer whose consumers present no credential (catalogue.CredentialFormNone): the vault mints nothing,
|
||||
// and the provision depends on no credential.
|
||||
FormNone = "none"
|
||||
)
|
||||
|
||||
// VaultName is the vault's name of a provision's credential in form, as its verb `own` names it and the provider asks
|
||||
// it: `pair/<provider node>/<provision>/<consumer node>/<module>[/<local>]` for a login of the consumer's own (ADR 0158
|
||||
// §11), `provision/<provider node>/<provision>` for the provider's one shared value, and `provision/<provider
|
||||
// node>/<provision>/<consumer node>/<module>` for a holder's own issued or given value. Empty for a form with no
|
||||
// credential.
|
||||
func VaultName(form string, p Provision) string {
|
||||
switch form {
|
||||
case FormPair:
|
||||
n := "pair/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module
|
||||
if p.Local != "" {
|
||||
n += "/" + p.Local
|
||||
}
|
||||
return n
|
||||
case FormShared:
|
||||
return "provision/" + p.Provider.Node + "/" + p.Provision
|
||||
case FormIssued, FormGiven:
|
||||
return "provision/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// VaultModule is the provider of every provision's credential (novox/hq ADR 0570 clauses 1, 6).
|
||||
const VaultModule = "mesh-vault"
|
||||
|
||||
// CredentialKey is the key of a provision's credential, itself a provision whose provider is the vault (clause 6; the
|
||||
// key form the lead settled on 2026-10-29): `<consumer node>.<consumer>.secret-<provision>[.<local>]` for a value of the
|
||||
// consumer's own (pair, issued, given), and `<provider node>.<provider>.secret-<provision>` for the provider's one
|
||||
// shared value. False for a form with no vault value, and for a provision the vault itself provides.
|
||||
func CredentialKey(form string, consumer, provider End, provision, local string) (string, bool) {
|
||||
if provider.Module == VaultModule {
|
||||
return "", false // the vault's own offer is the value: it has no credential beside it (the lead, 2026-10-29)
|
||||
}
|
||||
name := catalogue.CredentialProvisionPrefix + provision
|
||||
switch form {
|
||||
case FormPair, FormIssued, FormGiven:
|
||||
k, err := Key(consumer.Node, consumer.Module, name, local)
|
||||
return k, err == nil
|
||||
case FormShared:
|
||||
k, err := Key(provider.Node, provider.Module, name, "")
|
||||
return k, err == nil
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DependsOn is what a provision waits for before its provider makes it (clause 3): its credential's key, where its form
|
||||
// has a vault value, and the key of every provision its provider module itself requires on its own node, read from
|
||||
// the provider's plan there (nil when the provider is not handed and has none). Sorted, none twice.
|
||||
func DependsOn(consumer End, entry catalogue.PlanProvision, providerPlan *catalogue.Plan) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(k string) {
|
||||
if k != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
provider := End{Module: entry.ProviderModule, Node: entry.Provider}
|
||||
if k, ok := CredentialKey(entry.CredentialForm, consumer, provider, entry.Provision, entry.Local); ok {
|
||||
add(k)
|
||||
}
|
||||
if providerPlan != nil {
|
||||
for _, own := range providerPlan.Provisions {
|
||||
if k, err := Key(provider.Node, provider.Module, own.Provision, own.Local); err == nil {
|
||||
add(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// End is one end of a provision: a module on a node.
|
||||
type End struct {
|
||||
Module string `json:"module"`
|
||||
Node string `json:"node"`
|
||||
}
|
||||
|
||||
// Reference is a value the vault keeps, by its id and the generation of the value: never the value, sealed or not
|
||||
// (ADR 0349 §7). The holder writes the generation the vault says in its own entry (clause 6). ID is the vault's name of
|
||||
// the credential exactly as its verb `own` names it (VaultName): the consuming node's engine checks both the name and
|
||||
// the generation of what it is handed.
|
||||
type Reference struct {
|
||||
ID string `json:"id"`
|
||||
Generation int `json:"generation"`
|
||||
}
|
||||
|
||||
// Warrant is the operator's answer to a retiring provision's ask, as the holder writes it into the key (clause 5).
|
||||
type Warrant struct {
|
||||
Ask string `json:"ask"`
|
||||
Option string `json:"option"`
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// Provision is one key of `provisions`.
|
||||
type Provision struct {
|
||||
V int `json:"v"`
|
||||
Provision string `json:"provision"`
|
||||
Local string `json:"local,omitempty"`
|
||||
Consumer End `json:"consumer"`
|
||||
Provider End `json:"provider"`
|
||||
// DependsOn are the keys of the provisions this one waits for (clause 3): a database user whose password is the
|
||||
// vault's.
|
||||
DependsOn []string `json:"depends-on,omitempty"`
|
||||
State string `json:"state"`
|
||||
// Why is the reason for waits, failed and retiring, and for asked when a dependency holds it.
|
||||
Why string `json:"why,omitempty"`
|
||||
Reference *Reference `json:"reference,omitempty"`
|
||||
// Ask is a retiring provision's ask of the operator; Warrant the answer, once given.
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Warrant *Warrant `json:"warrant,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// Key is the provision's key.
|
||||
func (p Provision) Key() (string, error) {
|
||||
return Key(p.Consumer.Node, p.Consumer.Module, p.Provision, p.Local)
|
||||
}
|
||||
|
||||
func (p Provision) check() error {
|
||||
if _, err := p.Key(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Provider.Module == "" || p.Provider.Node == "" {
|
||||
return fmt.Errorf("provisions: %s names no provider", p.Provision)
|
||||
}
|
||||
known := false
|
||||
for _, s := range States {
|
||||
known = known || s == p.State
|
||||
}
|
||||
switch {
|
||||
case !known:
|
||||
return fmt.Errorf("provisions: a provision's state is one of %s, not %q", strings.Join(States, ", "), p.State)
|
||||
case (p.State == Waits || p.State == Failed || p.State == Retiring) && strings.TrimSpace(p.Why) == "":
|
||||
return fmt.Errorf("provisions: a %s provision says why", p.State)
|
||||
case p.Warrant != nil && p.State != Retiring:
|
||||
return fmt.Errorf("provisions: only a retiring provision carries the operator's warrant")
|
||||
}
|
||||
for _, d := range p.DependsOn {
|
||||
if _, _, _, _, err := ParseKey(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encode is p as JSON at Version; it refuses what a holder may not write.
|
||||
func Encode(p Provision) ([]byte, error) {
|
||||
p.V = Version
|
||||
if err := p.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// Decode reads a key's value strictly: a field the layout does not name is refused, so no value, sealed or not, rides in
|
||||
// a provision (clause 1, ADR 0201 §13).
|
||||
func Decode(data []byte) (Provision, error) {
|
||||
var p Provision
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
return Provision{}, fmt.Errorf("provisions: a provision is not readable: %w", err)
|
||||
}
|
||||
if p.V != Version {
|
||||
return Provision{}, fmt.Errorf("provisions: a provision of version %d, and this reads %d", p.V, Version)
|
||||
}
|
||||
return p, p.check()
|
||||
}
|
||||
|
||||
// Plan is one key of `plans`: a module's provisioning plan on a node, never a value (clause 2).
|
||||
type Plan struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Plan catalogue.Plan `json:"plan"`
|
||||
}
|
||||
|
||||
// EncodePlan is p as JSON at Version.
|
||||
func EncodePlan(p Plan) ([]byte, error) {
|
||||
p.V = Version
|
||||
if _, err := PlanKey(p.Node, p.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// DecodePlan reads a plan's key.
|
||||
func DecodePlan(data []byte) (Plan, error) {
|
||||
var p Plan
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return Plan{}, fmt.Errorf("provisions: a plan is not readable: %w", err)
|
||||
}
|
||||
if p.V != Version {
|
||||
return Plan{}, fmt.Errorf("provisions: a plan of version %d, and this reads %d", p.V, Version)
|
||||
}
|
||||
_, err := PlanKey(p.Node, p.Module)
|
||||
return p, err
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package provisions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// ADR 0570 clauses 1, 2 and 5 (issue 1954 L6-P2): a provision's key is its consumer's node and module, the provision and
|
||||
// its local name, the provider's entry's key; its value round-trips, names its state and why, and refuses any field the
|
||||
// layout does not name, so no value rides in it; a plan's key is its node and module.
|
||||
func TestAProvisionIsAReferenceUnderItsConsumersKey(t *testing.T) {
|
||||
at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
|
||||
db := Provision{Provision: "postgres-database", Consumer: End{Module: "keycloak", Node: "novox"},
|
||||
Provider: End{Module: "postgres", Node: "novox"}, DependsOn: []string{"novox.keycloak.secret.db"}, State: Asked,
|
||||
Reference: &Reference{ID: "provision/postgres/postgres-database", Generation: 2}, At: at}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
edit func(*Provision)
|
||||
key string
|
||||
refused string
|
||||
}{
|
||||
{name: "asked, depending on the vault's", key: "novox.keycloak.postgres-database"},
|
||||
{name: "under a local name", edit: func(p *Provision) { p.Local = "replica" }, key: "novox.keycloak.postgres-database.replica"},
|
||||
{name: "retiring with the operator's warrant", edit: func(p *Provision) {
|
||||
p.State, p.Why, p.Ask = Retiring, "keycloak left novox", "a-1"
|
||||
p.Warrant = &Warrant{Ask: "a-1", Option: "yes", By: "the operator", At: at}
|
||||
}, key: "novox.keycloak.postgres-database"},
|
||||
{name: "an unknown state", edit: func(p *Provision) { p.State = "done" }, refused: "one of"},
|
||||
{name: "failed without why", edit: func(p *Provision) { p.State = Failed }, refused: "says why"},
|
||||
{name: "a warrant on a provided one", edit: func(p *Provision) { p.State, p.Warrant = Provided, &Warrant{Ask: "a"} }, refused: "only a retiring"},
|
||||
{name: "no provider", edit: func(p *Provision) { p.Provider = End{} }, refused: "no provider"},
|
||||
{name: "a dotted consumer", edit: func(p *Provision) { p.Consumer.Module = "a.b" }, refused: "cannot be part"},
|
||||
{name: "a dependency that is no key", edit: func(p *Provision) { p.DependsOn = []string{"x"} }, refused: "is not"},
|
||||
} {
|
||||
p := db
|
||||
if c.edit != nil {
|
||||
c.edit(&p)
|
||||
}
|
||||
raw, err := Encode(p)
|
||||
if c.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
||||
t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused)
|
||||
}
|
||||
continue
|
||||
}
|
||||
back, err := Decode(raw)
|
||||
key, kerr := back.Key()
|
||||
if err != nil || kerr != nil || key != c.key || back.V != Version || back.Reference.Generation != 2 {
|
||||
t.Errorf("%s: read back as %+v under %q, %v %v", c.name, back, key, err, kerr)
|
||||
}
|
||||
if node, consumer, provision, local, err := ParseKey(key); err != nil || node != "novox" || consumer != "keycloak" ||
|
||||
provision != "postgres-database" || local != p.Local {
|
||||
t.Errorf("%s: %s parses as %s %s %s %s, %v", c.name, key, node, consumer, provision, local, err)
|
||||
}
|
||||
}
|
||||
// A value never rides in a provision: a field the layout does not name is refused.
|
||||
if _, err := Decode([]byte(`{"v":1,"provision":"p","consumer":{"module":"c","node":"n"},"provider":{"module":"p","node":"n"},` +
|
||||
`"state":"provided","at":"2026-10-29T22:00:00Z","password":"hunter2"}`)); err == nil {
|
||||
t.Error("a provision carrying a password was read")
|
||||
}
|
||||
plan := Plan{Node: "novox", Module: "restic", Plan: catalogue.Plan{}}
|
||||
raw, err := EncodePlan(plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back, err := DecodePlan(raw); err != nil || back.Node != "novox" || back.Module != "restic" {
|
||||
t.Errorf("a plan read back as %+v, %v", back, err)
|
||||
}
|
||||
if k, err := PlanKey("novox", "restic"); k != "novox.restic" || err != nil {
|
||||
t.Errorf("a plan's key is %q, %v", k, err)
|
||||
}
|
||||
// A reference's id is the vault's name as its verb own names it, in each form (the host agent, 2026-10-29).
|
||||
for form, want := range map[string]string{FormPair: "pair/novox/postgres-database/novox/keycloak", FormShared: "provision/novox/postgres-database",
|
||||
FormIssued: "provision/novox/postgres-database/novox/keycloak", FormNone: ""} {
|
||||
if got := VaultName(form, db); got != want {
|
||||
t.Errorf("the %s name is %q", form, got)
|
||||
}
|
||||
}
|
||||
if got := VaultName(FormPair, Provision{Provision: "p", Local: "r", Provider: End{Node: "a"}, Consumer: End{Node: "b", Module: "m"}}); got != "pair/a/p/b/m/r" {
|
||||
t.Errorf("a local name's pair is %q", got)
|
||||
}
|
||||
// A provision waits for its credential, the vault's provision `secret-<provision>`, and for what its provider itself
|
||||
// requires on its node (clause 3; the key form the lead settled on 2026-10-29).
|
||||
keycloak, postgres := End{Module: "keycloak", Node: "novox"}, End{Module: "postgres", Node: "ace"}
|
||||
providerPlan := &catalogue.Plan{Provisions: []catalogue.PlanProvision{{Provision: "backup-target", Provider: "ace", ProviderModule: "restic"}}}
|
||||
for _, c := range []struct {
|
||||
form string
|
||||
plan *catalogue.Plan
|
||||
want string
|
||||
}{
|
||||
{FormPair, nil, "novox.keycloak.secret-postgres-database"},
|
||||
{FormShared, nil, "ace.postgres.secret-postgres-database"},
|
||||
{FormPair, providerPlan, "ace.postgres.backup-target,novox.keycloak.secret-postgres-database"},
|
||||
{"", nil, ""},
|
||||
// An offer with no credential (the lead, 2026-10-29): nothing minted, nothing to wait for.
|
||||
{FormNone, nil, ""},
|
||||
} {
|
||||
entry := catalogue.PlanProvision{Provision: "postgres-database", Provider: postgres.Node, ProviderModule: postgres.Module,
|
||||
CredentialForm: c.form}
|
||||
if got := strings.Join(DependsOn(keycloak, entry, c.plan), ","); got != c.want {
|
||||
t.Errorf("%q depends on %q, want %q", c.form, got, c.want)
|
||||
}
|
||||
}
|
||||
// The vault's own offer is the value: no secret-secret beside it.
|
||||
vaultEntry := catalogue.PlanProvision{Provision: "secret", Provider: "novox", ProviderModule: VaultModule, CredentialForm: FormPair}
|
||||
if got := DependsOn(keycloak, vaultEntry, nil); len(got) != 0 {
|
||||
t.Errorf("the vault's secret depends on %v", got)
|
||||
}
|
||||
if _, err := catalogue.ParseManifest([]byte(`{"module":"x","version":"1","provides":[{"name":"secret-db","scope":"mesh"}]}`)); err == nil ||
|
||||
!strings.Contains(err.Error(), "secret-") {
|
||||
t.Errorf("a provision named secret-*: %v", err)
|
||||
}
|
||||
if ProvisionsBucket != "mesh-provisioner_provisions" || PlansRead != "mesh-provisioner.plans" {
|
||||
t.Errorf("the states are %s and %s", ProvisionsBucket, PlansRead)
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,12 @@ func (s KeptState) Create(key string, value any) (uint64, error) {
|
||||
return s.revisionOf(s.ask("create", map[string]any{"key": key, "value": value}))
|
||||
}
|
||||
|
||||
// CreateTTL is Create with the key's own time to live (PutTTL): refused as ErrChanged when the key has a value, so an act
|
||||
// begun once is not begun again; when ttl passes a watch hands the key over as OpExpire.
|
||||
func (s KeptState) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) {
|
||||
return s.revisionOf(s.ask("create", map[string]any{"key": key, "value": value, "ttl_seconds": ttlSeconds(ttl)}))
|
||||
}
|
||||
|
||||
// Update writes a key only when it is still at revision, and returns the new one; a key changed
|
||||
// meanwhile is refused, so of two writers that read one value only the first writes.
|
||||
func (s KeptState) Update(key string, value any, revision uint64) (uint64, error) {
|
||||
|
||||
+33
-1
@@ -20,7 +20,18 @@ type StateEntry struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
|
||||
// StateChange is one change a watch hands over: a key put or deleted.
|
||||
// The operations a watch hands over (StateChange.Op).
|
||||
const (
|
||||
// OpPut is a key written.
|
||||
OpPut = "put"
|
||||
// OpDelete is a key deleted or purged.
|
||||
OpDelete = "delete"
|
||||
// OpExpire is a key whose own time to live ended (CreateTTL, PutTTL): the runtime says the bus's marker of it as
|
||||
// "expire", so a watcher tells an expiry, which is the event a pulse or a bound waits for, from a delete.
|
||||
OpExpire = "expire"
|
||||
)
|
||||
|
||||
// StateChange is one change a watch hands over: a key put (OpPut), deleted (OpDelete) or expired (OpExpire).
|
||||
type StateChange struct {
|
||||
State string `json:"state"`
|
||||
Key string `json:"key"`
|
||||
@@ -98,6 +109,27 @@ func (s KeptState) Put(key string, value any) (uint64, error) {
|
||||
return put.Revision, json.Unmarshal(raw, &put)
|
||||
}
|
||||
|
||||
// PutTTL writes a key whole with its own time to live — the owner's instances only — and returns the revision written:
|
||||
// the bus removes it when ttl passes and a watch hands that over as OpExpire. The state is declared `key-ttl`.
|
||||
func (s KeptState) PutTTL(key string, value any, ttl time.Duration) (uint64, error) {
|
||||
raw, err := s.ask("put", map[string]any{"key": key, "value": value, "ttl_seconds": ttlSeconds(ttl)})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var put struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
return put.Revision, json.Unmarshal(raw, &put)
|
||||
}
|
||||
|
||||
// ttlSeconds is a time to live as the runtime takes it, whole seconds, at least one.
|
||||
func ttlSeconds(ttl time.Duration) int64 {
|
||||
if s := int64(ttl / time.Second); s > 1 {
|
||||
return s
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// Delete removes a key — the owner's instances only. A key that was not there is not an error.
|
||||
func (s KeptState) Delete(key string) error {
|
||||
_, err := s.ask("delete", map[string]any{"key": key})
|
||||
|
||||
@@ -3,6 +3,8 @@ package stdio
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -33,6 +35,12 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
|
||||
return State("servers").Put(a["key"].(string), a["value"])
|
||||
}},
|
||||
{Name: "recall", Run: func(a map[string]any) (any, error) { return State("servers").Get(a["key"].(string)) }},
|
||||
{Name: "pulse", Run: func(a map[string]any) (any, error) {
|
||||
if a["create"] == true {
|
||||
return State("servers").CreateTTL(a["key"].(string), 1, 90*time.Second)
|
||||
}
|
||||
return State("servers").PutTTL(a["key"].(string), 1, 90*time.Second)
|
||||
}},
|
||||
}, inR, outW)
|
||||
}()
|
||||
lines := bufio.NewScanner(outR)
|
||||
@@ -87,6 +95,15 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
|
||||
if err := <-watched; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 1952 (the runtime's per-key time to live, mesh-tools 1583fa85): an expiry is handed over as its own operation.
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 49, "method": "mesh/state", "params": map[string]any{"watch": watch["id"],
|
||||
"change": map[string]any{"state": "servers", "key": "all.gone", "op": OpExpire}}})
|
||||
if r := next(); r["id"] != float64(49) || r["error"] != nil {
|
||||
t.Fatalf("an expiry was answered %v", r)
|
||||
}
|
||||
if s := <-seen; s != "expire all.gone " {
|
||||
t.Fatalf("handled %q", s)
|
||||
}
|
||||
// A change for a watch this bundle never asked for is refused.
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 51, "method": "mesh/state", "params": map[string]any{"watch": "bundle-999",
|
||||
"change": map[string]any{"key": "x", "op": "put"}}})
|
||||
@@ -105,6 +122,32 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
|
||||
if r := next(); r["id"] != float64(2) || r["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"] != "9" {
|
||||
t.Fatalf("the put answered %v", r)
|
||||
}
|
||||
// PutTTL and CreateTTL ask the runtime's put and create with the key's time to live in whole seconds; a create the
|
||||
// runtime refuses as changed (-32010) is ErrChanged.
|
||||
for i, create := range []bool{false, true} {
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 20 + i, "method": "tools/call", "params": map[string]any{"name": "pulse",
|
||||
"arguments": map[string]any{"key": "pulse.S1", "create": create}}})
|
||||
asked := next()
|
||||
p := asked["params"].(map[string]any)
|
||||
want := map[bool]string{false: "mesh/state.put", true: "mesh/state.create"}[create]
|
||||
if asked["method"] != want || p["ttl_seconds"] != float64(90) || p["key"] != "pulse.S1" {
|
||||
t.Fatalf("asked %v", asked)
|
||||
}
|
||||
if create {
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": asked["id"], "error": map[string]any{"code": CodeChanged, "message": "exists"}})
|
||||
if r := next(); r["id"] != float64(21) || !strings.Contains(fmt.Sprint(r), "exists") {
|
||||
t.Fatalf("a refused create answered %v", r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": asked["id"], "result": map[string]any{"revision": 4}})
|
||||
if r := next(); r["id"] != float64(20) {
|
||||
t.Fatalf("the put answered %v", r)
|
||||
}
|
||||
}
|
||||
if !errors.Is(&refused{code: CodeChanged, message: "x"}, ErrChanged) {
|
||||
t.Fatal("a refusal coded -32010 is not ErrChanged")
|
||||
}
|
||||
// Get of a key with no value is nil.
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": map[string]any{"name": "recall",
|
||||
"arguments": map[string]any{"key": "nothing"}}})
|
||||
|
||||
+26
-2
@@ -72,6 +72,8 @@ type channel struct {
|
||||
watches map[string]func(StateChange) error
|
||||
// provisioned are this bundle's watches of its values, by the id each was asked with (provision.go).
|
||||
provisioned map[string]func(ProvisionChange) error
|
||||
// logWatches are this bundle's watches of a log, by the id each was asked with (log.go).
|
||||
logWatches map[string]func(LogEntry) error
|
||||
// takers and provers are this bundle's seat work and proof handlers, by worker and subject (ADR 0259).
|
||||
takers map[string]func(Work) error
|
||||
provers map[string]func(subject string, body json.RawMessage) (any, error)
|
||||
@@ -182,8 +184,8 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
|
||||
byName[t.Name] = t
|
||||
}
|
||||
c := &channel{enc: json.NewEncoder(out), waiting: map[string]chan message{}, watches: map[string]func(StateChange) error{},
|
||||
provisioned: map[string]func(ProvisionChange) error{},
|
||||
takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}}
|
||||
provisioned: map[string]func(ProvisionChange) error{}, logWatches: map[string]func(LogEntry) error{},
|
||||
takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}}
|
||||
currentMu.Lock()
|
||||
current = c
|
||||
currentMu.Unlock()
|
||||
@@ -315,6 +317,28 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
|
||||
}
|
||||
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
||||
}(id, *p.Change)
|
||||
case "mesh/log":
|
||||
// One entry of a log this bundle watches (log.go), named by the watch's id, answered once its handler has
|
||||
// taken it, in order: the runtime sends the next only after this answer.
|
||||
var p struct {
|
||||
Watch string `json:"watch"`
|
||||
Entry *LogEntry `json:"entry"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &p)
|
||||
c.mu.Lock()
|
||||
handle := c.logWatches[p.Watch]
|
||||
c.mu.Unlock()
|
||||
if handle == nil || p.Entry == nil {
|
||||
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/log names no watch of this bundle"}})
|
||||
continue
|
||||
}
|
||||
go func(id any, e LogEntry) {
|
||||
if err := handle(e); err != nil {
|
||||
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
||||
}(id, *p.Entry)
|
||||
case "mesh/provision":
|
||||
// One change to a value of this module's that this bundle watches (provision.go), named by the
|
||||
// watch's id, answered once its handler has taken it. Beside the loop, like a tool call.
|
||||
|
||||
Reference in New Issue
Block a user