Merge pull request 'Readers leave the controller: hand acts, packages, compare, provisioner on the watched reader (design 47 L5-L7)' (#586) from feat/l5-l7-main into main

This commit was merged in pull request #586.
This commit is contained in:
2026-11-01 13:53:20 +00:00
37 changed files with 502 additions and 140 deletions
+1 -4
View File
@@ -2,7 +2,4 @@
# the fallback of a recorder (mesh-sdk handacts.Recorder) while the seat mesh-hand-acts has no holder that answers, until # the fallback of a recorder (mesh-sdk handacts.Recorder) while the seat mesh-hand-acts has no holder that answers, until
# the controller's lease is written to a holder that is no controller. The check of this directory (handacts) refuses any # the controller's lease is written to a holder that is no controller. The check of this directory (handacts) refuses any
# other, and an entry whose module no longer invokes it: the list only shrinks, and it is empty before the lease write # other, and an entry whose module no longer invokes it: the list only shrinks, and it is empty before the lease write
# (novox/hq ADR 0565 §11; issue 1953, issue 774). # (novox/hq ADR 0565 §11; issue 1953, issue 774). It is empty: no manifest invokes the controller's log (issue 1953, 1955).
mesh-config hand-act fallback until the lease write: its sets, clears and held values record through mesh-hand-acts first
mesh-config warranted fallback until the lease write: its warrants record through mesh-hand-acts first
mesh-healers drill fallback until the lease write: a drill records through mesh-hand-acts first
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
) )
@@ -260,16 +261,25 @@ func TestAHomeItemIsImportedAndTheStaleOnesAreNamed(t *testing.T) {
} }
} }
// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced. // The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced. It invokes the
// hand-act seat's warranted and no verb of the controller, and the warrant is asked of that seat (ADR 0565 clause 11).
func TestTheManifestListsEveryToolServed(t *testing.T) { func TestTheManifestListsEveryToolServed(t *testing.T) {
raw, err := os.ReadFile("../../module.json") raw, err := os.ReadFile("../../module.json")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var m struct { var m struct {
Tools []string `json:"tools"` Tools []string `json:"tools"`
Invokes []string `json:"invokes"`
} }
_ = json.Unmarshal(raw, &m) _ = json.Unmarshal(raw, &m)
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted"}) {
t.Errorf("invokes = %v, want the hand-act seat's warranted alone", m.Invokes)
}
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
strings.Contains(string(src), "seat:mesh-controller.warranted") {
t.Error("recordWarrant does not ask the hand-act seat alone")
}
listed := map[string]bool{} listed := map[string]bool{}
for _, n := range m.Tools { for _, n := range m.Tools {
listed[n] = true listed[n] = true
@@ -696,7 +696,7 @@ type ProposalStore interface {
Get(key string) (json.RawMessage, bool, error) Get(key string) (json.RawMessage, bool, error)
} }
// Recorder records a warrant in the controller's hand-act log: the controller reads the warrant from the // Recorder records a warrant in the hand-act log of the seat mesh-hand-acts: the controller reads the warrant from the
// router's own record, never from this module. // router's own record, never from this module.
type Recorder func(ask string) error type Recorder func(ask string) error
@@ -708,9 +708,9 @@ func (sdkAskBus) Publish(subject string, body any, id string) error {
func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) } func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) }
func (sdkAskBus) Kinds() ([]stdio.KindHeld, error) { return stdio.SeatKinds() } func (sdkAskBus) Kinds() ([]stdio.KindHeld, error) { return stdio.SeatKinds() }
// recordWarrant asks the controller to record a warrant this module acted on. // recordWarrant asks the seat mesh-hand-acts (ADR 0565 clause 11) to record a warrant this module acted on.
func recordWarrant(ask string) error { func recordWarrant(ask string) error {
_, err := stdio.Ask("seat:mesh-controller.warranted", map[string]any{"asker": Module, "ask": ask}) _, err := stdio.Ask("seat:mesh-hand-acts.warranted", map[string]any{"asker": Module, "ask": ask})
return err return err
} }
+1 -1
View File
@@ -2,7 +2,7 @@ module claude-code
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027
require gopkg.in/yaml.v3 v3.0.1 require gopkg.in/yaml.v3 v3.0.1
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
+3
View File
@@ -22,6 +22,9 @@
"uses": [ "uses": [
"operator-channel" "operator-channel"
], ],
"invokes": [
"seat:mesh-hand-acts.warranted"
],
"state": [ "state": [
"servers", "servers",
"holdings", "holdings",
+1 -1
View File
@@ -3,7 +3,7 @@ module gitea
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+7 -17
View File
@@ -9,13 +9,13 @@
// //
// module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239) // module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239)
// //
// Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head // Once consumed mesh-controller.checked — a pull request's merge check, judged — and set it as the head
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and // commit's statuses (setVerdict, kept below for a revert, and bound to no event since ADR 0571 clause 13): `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment // `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base // saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere, hq included since // still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere, hq included since
// ADR 0527 §2, and since R4g no controller judges a merge (its merge gate went with build-agent, ADR 0524 §5), so nothing // ADR 0527 §2, and since R4g no controller judges a merge (its merge gate went with build-agent, ADR 0524 §5), so nothing
// publishes mesh-controller.checked and these two are written for no base. // publishes a merge check's verdict, this module consumes no event of the controller, and these two are written for no base.
// //
// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and // Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and
// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands // writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands
@@ -43,7 +43,7 @@
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering // The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
// the forge for an immediacy nobody asked for is not. // the forge for an immediacy nobody asked for is not.
import { emit, on } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { registerModuleTools } from "@novox/mesh-sdk/tools"; import { registerModuleTools } from "@novox/mesh-sdk/tools";
import { statesAnnouncement } from "./kept.js"; import { statesAnnouncement } from "./kept.js";
import { commitStatusTool } from "./delivery.js"; import { commitStatusTool } from "./delivery.js";
@@ -649,19 +649,9 @@ if (gitea) {
setTimeout(() => { catchUpAsked = false; void pass(); }, 30_000); setTimeout(() => { catchUpAsked = false; void pass(); }, 30_000);
}; };
void listenOk.then((ok) => (ok ? pass() : undefined)); void listenOk.then((ok) => (ok ? pass() : undefined));
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its // No consumer is bound since ADR 0571 clause 13 took the controller's checked out of this module; a consumer added later
// handshake in 10 s, so the import must end while a refused bind is still asked again. // is bound again until it holds, and not awaited (bind.ts, issue 1793).
bindInBackground([ bindInBackground([], () => console.log("[gitea] watching for new repositories and merged pull requests"));
["mesh-controller.checked", () => on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
if (!c.group && c.owner && c.repo && c.commit) {
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
}
return setVerdict(client, event);
})],
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
// The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again, // The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again,
// apart from the binds above, so neither waits for the other. // apart from the binds above, so neither waits for the other.
void watchOwed((handler) => state<OwedChange["value"]>(CHECKER_OWED).watch((change) => handler(change)), checks); void watchOwed((handler) => state<OwedChange["value"]>(CHECKER_OWED).watch((change) => handler(change)), checks);
-3
View File
@@ -86,9 +86,6 @@
"forge.wiki.changed", "forge.wiki.changed",
"forge.other" "forge.other"
], ],
"consumes": [
"mesh-controller.checked"
],
"listens": [ "listens": [
{ {
"name": "web", "name": "web",
+1 -1
View File
@@ -34,7 +34,7 @@ test("the checker's owed is what this module reads, and it consumes no check.sta
assert.equal(CHECKER_OWED, "mesh-delivery-checker.owed"); assert.equal(CHECKER_OWED, "mesh-delivery-checker.owed");
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
assert.ok(manifest.reads.includes(CHECKER_OWED), "gitea's manifest reads the checker's owed"); assert.ok(manifest.reads.includes(CHECKER_OWED), "gitea's manifest reads the checker's owed");
assert.ok(!manifest.consumes.some((c: string) => c.includes("check.status")), "no check.status is consumed"); assert.ok(!(manifest.consumes ?? []).some((c: string) => c.includes("check.status")), "no check.status is consumed");
}); });
test("an owed mesh/check writes the status on its head: its state, its line cut and its link", async () => { test("an owed mesh/check writes the status on its head: its state, its line cut and its link", async () => {
+2 -2
View File
@@ -75,8 +75,8 @@ func (runtimeStore) Read(log string) ([]LogEntry, error) {
func (runtimeStore) Emit(event string, body any) error { return stdio.Emit(event, body) } func (runtimeStore) Emit(event string, body any) error { return stdio.Emit(event, body) }
// recorder records into the hand-act log of the seat mesh-hand-acts, falling back to the controller's while the seat // recorder records into the hand-act log of the seat mesh-hand-acts alone (novox/hq ADR 0565 §11, issue 1953): the manifest
// has no holder that answers and the controller still holds its lease (novox/hq ADR 0565 §11). // invokes no verb of the controller and the SDK recorder has no fall back to its log.
var recorder = handacts.Recorder{Ask: stdio.Ask, LeaseAway: func() bool { var recorder = handacts.Recorder{Ask: stdio.Ask, LeaseAway: func() bool {
e, err := stdio.State(LeaseRead).Get(buscompose.ControllerLeaseKey) e, err := stdio.State(LeaseRead).Get(buscompose.ControllerLeaseKey)
var raw []byte var raw []byte
@@ -350,6 +350,20 @@ func (c *Config) compare() (*Comparison, error) {
c.mirrorDeclarations(&mirror) c.mirrorDeclarations(&mirror)
var diffs []Difference var diffs []Difference
// A placed module no catalog manifest names (mesh-wireguard, which the mesh ships computed) whose mirror key holds
// no config key declares nothing on both sides: nothing resolves from it, so nothing differs (issue 1953, L5d).
declaresNothing := map[string]bool{}
for module, d := range mirror.Declared {
if len(d.Config) == 0 {
// Only an indexer that was read and holds no modules.<module>: a failed read says nothing, and the row stays.
if key, kerr := delivery.ModulesKey(module); kerr == nil {
if e, err := c.Store.Get(ModulesRead, key); err == nil && e == nil {
declaresNothing[module] = true
}
}
}
}
// Each placed module's declarations: `declared` against the mirror's key for it. // Each placed module's declarations: `declared` against the mirror's key for it.
for _, module := range modulesOf(own.Assignments) { for _, module := range modulesOf(own.Assignments) {
var ownRaw []byte var ownRaw []byte
@@ -364,6 +378,7 @@ func (c *Config) compare() (*Comparison, error) {
mirrorRaw, _ = json.Marshal(normal(d.Config)) mirrorRaw, _ = json.Marshal(normal(d.Config))
} }
switch { switch {
case ownRaw == nil && declaresNothing[module]:
case ownRaw == nil && mirrorRaw == nil: case ownRaw == nil && mirrorRaw == nil:
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Why: "neither side holds its declarations"}) diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Why: "neither side holds its declarations"})
case ownRaw == nil: case ownRaw == nil:
@@ -412,9 +427,15 @@ func (c *Config) compare() (*Comparison, error) {
if cerr != nil { if cerr != nil {
diffs = append(diffs, Difference{What: DiffPull, Why: "the indexer's config candidates cannot be read: " + cerr.Error()}) diffs = append(diffs, Difference{What: DiffPull, Why: "the indexer's config candidates cannot be read: " + cerr.Error()})
} }
settled := map[string]string{} // a pull request the forge says is no longer open → how it ended
for _, k := range unionKeys(cands, own.Pulls) { for _, k := range unionKeys(cands, own.Pulls) {
a, aok := cands[k] a, aok := cands[k]
b, bok := own.Pulls[k] b, bok := own.Pulls[k]
isSettled, forgeNote := c.pullSettled(firstNonEmpty(a.Pull, b.Pull), settled)
if isSettled {
continue // a merged or closed pull request is no candidate to agree on (the operator, 2026-11-01)
}
before := len(diffs)
switch { switch {
case !aok: case !aok:
diffs = append(diffs, Difference{What: DiffPull, Pull: b.Pull, Why: "the controller told its declarations and the indexer holds no candidate"}) diffs = append(diffs, Difference{What: DiffPull, Pull: b.Pull, Why: "the controller told its declarations and the indexer holds no candidate"})
@@ -426,6 +447,9 @@ func (c *Config) compare() (*Comparison, error) {
case declaresDigest(a.Declares) != declaresDigest(b.Declares): case declaresDigest(a.Declares) != declaresDigest(b.Declares):
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the two declare its config otherwise"}) diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the two declare its config otherwise"})
} }
if forgeNote != "" && len(diffs) > before {
diffs[len(diffs)-1].Why += "; " + forgeNote // the row stays because the forge was not heard: say so in the row
}
} }
// Every placed module's value on every node, resolved both ways. // Every placed module's value on every node, resolved both ways.
@@ -456,6 +480,9 @@ func (c *Config) compare() (*Comparison, error) {
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from the mirror: " + u.Why}) diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from the mirror: " + u.Why})
} }
for _, u := range own.Unresolved { for _, u := range own.Unresolved {
if declaresNothing[u.Module] {
continue
}
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from its own source: " + u.Why}) diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from its own source: " + u.Why})
} }
sort.SliceStable(diffs, func(i, j int) bool { sort.SliceStable(diffs, func(i, j int) bool {
@@ -474,6 +501,53 @@ func (c *Config) compare() (*Comparison, error) {
return cmp, nil return cmp, nil
} }
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// pullSettled is whether the forge says a pull request merged or closed. Without a forge, or when it does not say, the
// pull request counts as open, so its row stays (failure is loud).
func (c *Config) pullSettled(pull string, memo map[string]string) (bool, string) {
if c.Forge == nil || pull == "" {
return false, ""
}
if v, ok := memo[pull]; ok {
return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v)
}
owner, repo, n, err := parsePull(pull)
if err != nil {
return false, ""
}
open, merged, _, err := c.Forge.Pull(owner, repo, n)
switch {
case err != nil:
c.logf("[mesh-config] compare: the forge does not say whether %s is open (%v); its row stays", pull, err)
memo[pull] = forgeSilent + err.Error()
case open:
memo[pull] = ""
case merged:
memo[pull] = "merged"
default:
memo[pull] = "closed"
}
v := memo[pull]
return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v)
}
// forgeSilent marks a memo entry for a pull request the forge could not be asked about; the rest of the entry is why.
const forgeSilent = "forge-silent: "
// forgeNoteOf is the words a row carries when the forge was not heard on its pull request, or "".
func forgeNoteOf(v string) string {
if why, ok := strings.CutPrefix(v, forgeSilent); ok {
return "the forge does not say whether the pull request is open (" + why + ")"
}
return ""
}
// normal is declarations in one encoding: a null default as none. // normal is declarations in one encoding: a null default as none.
func normal(d map[string]Declaration) map[string]Declaration { func normal(d map[string]Declaration) map[string]Declaration {
out := map[string]Declaration{} out := map[string]Declaration{}
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -88,9 +89,24 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
{"a pull request only the indexer holds", func(t *testing.T, m *memStore) { {"a pull request only the indexer holds", func(t *testing.T, m *memStore) {
candidate(t, m, "novox/mesh-catalog#9", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`) candidate(t, m, "novox/mesh-catalog#9", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#9"}}, }, []string{"pull:novox/mesh-catalog#9"}},
{"a candidate of a merged pull request is no difference", func(t *testing.T, m *memStore) {
candidate(t, m, "novox/mesh-catalog#11", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
candidate(t, m, "novox/mesh-catalog#10", "h2", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#10"}},
{"a forge that cannot be asked keeps the row and says why in it", func(t *testing.T, m *memStore) {
candidate(t, m, "novox/mesh-catalog#12", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#12"}},
{"a module no manifest names and the mirror declares nothing of", func(t *testing.T, m *memStore) {
m.assign(t, "g14", "mesh-wireguard")
m.mirror(t, MirrorDeclaredRead, "mesh-wireguard", `{"module":"mesh-wireguard","settings":{}}`)
}, nil},
} }
for _, tc := range cases { for _, tc := range cases {
c, m, r := anAgreeingMesh(t) c, m, r := anAgreeingMesh(t)
c.Forge = &standForge{state: map[string][2]bool{"novox/mesh-catalog#11": {false, true}}}
if strings.Contains(tc.name, "forge that cannot be asked") {
c.Forge = &standForge{err: errors.New("forge down")}
}
tc.change(t, m) tc.change(t, m)
cmp, err := c.Compare() cmp, err := c.Compare()
if err != nil { if err != nil {
@@ -103,6 +119,9 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
if d.Why == "" { if d.Why == "" {
t.Errorf("%s: a difference without its reason: %+v", tc.name, d) t.Errorf("%s: a difference without its reason: %+v", tc.name, d)
} }
if strings.Contains(tc.name, "forge that cannot be asked") && !strings.Contains(d.Why, "forge down") {
t.Errorf("%s: the row does not say the forge was not heard: %q", tc.name, d.Why)
}
} }
a, err := c.TakeOver(Call{Why: "compare is empty", Caller: "person.jochen"}) a, err := c.TakeOver(Call{Why: "compare is empty", Caller: "person.jochen"})
if len(tc.want) > 0 { if len(tc.want) > 0 {
+1 -1
View File
@@ -2,7 +2,7 @@ module mesh-config
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-2
View File
@@ -28,8 +28,6 @@
"invokes": [ "invokes": [
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-act",
"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.warranted",
"seat:mesh-controller.hand-act",
"seat:mesh-controller.warranted",
"gitea.gitea_get_pull_request" "gitea.gitea_get_pull_request"
], ],
"reads": [ "reads": [
@@ -442,8 +442,8 @@ func TestTheVerbsAndReads(t *testing.T) {
if a["acting"] != true || len(a["acts"].([]Attempt)) != 1 || len(a["healers"].([]map[string]any)) != 3 { if a["acting"] != true || len(a["acts"].([]Attempt)) != 1 || len(a["healers"].([]map[string]any)) != 3 {
t.Errorf("healers answered %v", a) t.Errorf("healers answered %v", a)
} }
// drill records into mesh-hand-acts as a drill; while that seat has no holder, into the controller's drill until // drill records into mesh-hand-acts as a drill, and never into the controller's log, whoever holds the lease
// the lease is handed away (ADR 0565 §11). // (ADR 0565 §11, issue 1955).
drills := []struct { drills := []struct {
name string name string
seatGone bool seatGone bool
@@ -452,8 +452,9 @@ func TestTheVerbsAndReads(t *testing.T) {
log, err string log, err string
}{ }{
{"into the seat", false, true, map[string]any{"what": "stopped records", "why": "does it raise"}, handacts.LogSeat, ""}, {"into the seat", false, true, map[string]any{"what": "stopped records", "why": "does it raise"}, handacts.LogSeat, ""},
{"no holder: the controller's", true, false, map[string]any{"what": "stopped records", "why": "x"}, handacts.LogController, ""}, {"into the seat under a controller's lease", false, false, map[string]any{"what": "stopped records", "why": "x"}, handacts.LogSeat, ""},
{"no holder, lease away", true, true, map[string]any{"what": "stopped records", "why": "x"}, "", "handed its lease away"}, {"no holder: refused, the controller's not asked", true, false, map[string]any{"what": "stopped records", "why": "x"}, "", "nothing was recorded"},
{"no holder, lease away", true, true, map[string]any{"what": "stopped records", "why": "x"}, "", "nothing was recorded"},
{"says nothing", false, true, map[string]any{}, "", "says what"}, {"says nothing", false, true, map[string]any{}, "", "says what"},
} }
for _, d := range drills { for _, d := range drills {
@@ -489,6 +490,11 @@ func TestTheVerbsAndReads(t *testing.T) {
case d.log == handacts.LogSeat && !strings.Contains(asked[0], `"verb":"hand-act drill"`): case d.log == handacts.LogSeat && !strings.Contains(asked[0], `"verb":"hand-act drill"`):
t.Errorf("drill %s asked %v", d.name, asked) t.Errorf("drill %s asked %v", d.name, asked)
} }
for _, k := range asked {
if strings.Contains(k, "mesh-controller") {
t.Errorf("drill %s asked the controller: %v", d.name, asked)
}
}
} }
if _, err := w.h.read(WouldState, ""); err != nil { if _, err := w.h.read(WouldState, ""); err != nil {
t.Error(err) t.Error(err)
@@ -49,7 +49,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
}{ }{
{"emits (healer-acted is the seat's, said on its subject)", m.Emits, nil}, {"emits (healer-acted is the seat's, said on its subject)", m.Emits, nil},
{"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent, RecordedEvent}}, {"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent, RecordedEvent}},
{"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-controller.drill"}}, {"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act"}},
{"reads", m.Reads, []string{LeaseRead, ConditionsRead, HandActsRead}}, {"reads", m.Reads, []string{LeaseRead, ConditionsRead, HandActsRead}},
} { } {
if !slices.Equal(c.got, c.want) { if !slices.Equal(c.got, c.want) {
+7 -21
View File
@@ -57,8 +57,7 @@ func Tools(h *Healers) []stdio.Tool {
}}, }},
{Name: DrillTool, {Name: DrillTool,
Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with what it " + Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with what it " +
"tests, in the hand-act log of the seat mesh-hand-acts (the controller's drill while that seat has no " + "tests, in the hand-act log of the seat mesh-hand-acts. A drill is never a repair: S15 never counts it.",
"holder and the controller holds its lease). A drill is never a repair: S15 never counts it.",
Input: obj(map[string]any{"what": map[string]any{"type": "string", "description": "what was done on purpose"}, Input: obj(map[string]any{"what": map[string]any{"type": "string", "description": "what was done on purpose"},
"why": map[string]any{"type": "string", "description": "what the drill tests"}, "why": map[string]any{"type": "string", "description": "what the drill tests"},
"condition": map[string]any{"type": "string", "description": "the condition it is meant to raise, if any"}}), "condition": map[string]any{"type": "string", "description": "the condition it is meant to raise, if any"}}),
@@ -85,8 +84,8 @@ func Tools(h *Healers) []stdio.Tool {
} }
// Drill records a drill (ADR 0565 §11, which ends ADR 0564 Limits 1's refusal): through the seat mesh-hand-acts as // Drill records a drill (ADR 0565 §11, which ends ADR 0564 Limits 1's refusal): through the seat mesh-hand-acts as
// the act `hand-act drill`, and while that seat has no holder that answers and the controller still holds its lease, // the act `hand-act drill`. The controller's own `drill` is not asked (issue 1955): a seat with no holder that answers
// through the controller's own `drill`, which records it as a drill too. It answers which log took it. // refuses the drill, said whole, and nothing is recorded in a second log.
func (h *Healers) Drill(what, why, condition string) (any, error) { func (h *Healers) Drill(what, why, condition string) (any, error) {
if what == "" || why == "" { if what == "" || why == "" {
return nil, errors.New("a drill says what was done on purpose and what it tests: nothing was recorded") return nil, errors.New("a drill says what was done on purpose and what it tests: nothing was recorded")
@@ -95,27 +94,14 @@ func (h *Healers) Drill(what, why, condition string) (any, error) {
return nil, errors.New("the hand-act log cannot be reached from here: nothing was recorded") return nil, errors.New("the hand-act log cannot be reached from here: nothing was recorded")
} }
body := map[string]any{"what": what, "why": why, "verb": handacts.VerbDrill} body := map[string]any{"what": what, "why": why, "verb": handacts.VerbDrill}
controller := map[string]any{"what": what, "why": why}
if condition != "" { if condition != "" {
body["condition"], controller["condition"] = condition, condition body["condition"] = condition
} }
answer, err := h.Ask("seat:"+handacts.Seat+".hand-act", body) answer, err := h.Ask("seat:"+handacts.Seat+".hand-act", body)
if err == nil { if err != nil {
return map[string]any{"log": handacts.LogSeat, "answer": answer}, nil return nil, fmt.Errorf("%s.hand-act refused the drill, so nothing was recorded: %w", handacts.Seat, err)
} }
if !handacts.NoResponder(err) { return map[string]any{"log": handacts.LogSeat, "answer": answer}, nil
return nil, fmt.Errorf("%s.hand-act refused the drill: %w", handacts.Seat, err)
}
if acting, _ := h.Acting(); acting {
return nil, fmt.Errorf("%s has no holder that answers, and the controller handed its lease away: nothing was "+
"recorded (%w)", handacts.Seat, err)
}
answer, cerr := h.Ask("seat:mesh-controller.drill", controller)
if cerr != nil {
return nil, fmt.Errorf("%s has no holder that answers (%v), and the controller's drill refused too: %w", handacts.Seat,
err, cerr)
}
return map[string]any{"log": handacts.LogController, "answer": answer}, nil
} }
// Heal is the verb heal: one condition judged now by its healer. // Heal is the verb heal: one condition judged now by its healer.
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-healers
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -2
View File
@@ -20,8 +20,7 @@
], ],
"invokes": [ "invokes": [
"mesh-grants.grants_assert", "mesh-grants.grants_assert",
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-act"
"seat:mesh-controller.drill"
], ],
"reads": [ "reads": [
"mesh-controller.lease", "mesh-controller.lease",
@@ -29,6 +29,7 @@ import (
"sync" "sync"
"time" "time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/compose/frombus"
@@ -1044,12 +1045,52 @@ func orSaid(words ...string) string {
return "" return ""
} }
// FromBus is the holder's composer over the bus: frombus's plans of a node, at the builds keptAt names, read through // FromWatched is the holder's composer over one watched copy of its reads (novox/hq ADR 0580 clause 2): frombus's plans
// this module's runtime with its own `reads` (frombus.StatesReads). A plan names no value, so no own secret and no // of a node, at the builds keptAt names, read from the copy (placement, haves, modules, resolved values), a build by key
// credential is read (compose.Planning). // and a manifest once per digest; no state is read key by key per composition. A plan names no value, so no own secret
func FromBus() Composer { // and no credential is read (compose.Planning).
r := fromruntime.Reader() func FromWatched(ws *stdio.WatchedStates) Composer {
r := fromruntime.WatchedReader(ws)
return func(ctx context.Context, node string, keptAt map[string]string) (map[string]catalogue.Plan, map[string]string, error) { return func(ctx context.Context, node string, keptAt map[string]string) (map[string]catalogue.Plan, map[string]string, error) {
return frombus.PlansHeld(ctx, r, frombus.Mesh{}, frombus.Scope{Node: node, KeptAt: keptAt}) return frombus.PlansHeld(ctx, r, frombus.Mesh{}, frombus.Scope{Node: node, KeptAt: keptAt})
} }
} }
// WatchedReads are the states the holder reads whole, each from the watched copy: what the composer reads and the
// wants (ADR 0580 clause 2). Its own states and the providers' entries stay read from the bus, after its own writes.
var WatchedReads = []string{PlacementRead, WantsRead, ModulesRead, HaveRead, ResolvedRead}
// Watching is a Store whose WatchedReads come from the copy; every other state, and every write, from s.
func Watching(s Store, ws *stdio.WatchedStates) Store { return watchingStore{Store: s, ws: ws} }
type watchingStore struct {
Store
ws *stdio.WatchedStates
}
func (w watchingStore) Get(state, key string) (*Entry, error) {
if !slices.Contains(WatchedReads, state) {
return w.Store.Get(state, key)
}
v, found, err := w.ws.Get(state, key)
if err != nil || !found {
return nil, err
}
return &Entry{Key: key, Value: v}, nil
}
func (w watchingStore) Keys(state string) ([]string, error) {
if !slices.Contains(WatchedReads, state) {
return w.Store.Keys(state)
}
return w.ws.Keys(state)
}
// NodesOfNodeKey is the node a key that starts with it names: a resolved value's `<node>.<module>.<key>`, a have's
// `<node>.<module>` or `<node>.node`. A change of either recomposes that node (ADR 0580 clause 2).
func NodesOfNodeKey(key string) []string {
if n, _, ok := strings.Cut(key, "."); ok && n != "" {
return []string{n}
}
return nil
}
@@ -3,8 +3,9 @@
package main package main
// The holder against a real bus (novox/hq ADR 0570 clauses 1-3, 5; issue 1954 H5): its states and every input it reads // The holder against a real bus (novox/hq ADR 0570 clauses 1-3, 5; issue 1954 H5): its states and every input it reads
// are a real nats-server's key-value buckets, its plans composed by mesh-sdk's composition from bus state over the // are a real nats-server's key-value buckets, its plans composed by mesh-sdk's composition from the watched copy of those
// in-memory mesh frombustest gives (restic on n1, requiring postgres-database from postgres there). A placement and a // buckets (ADR 0580 clause 2) filled with the mesh frombustest gives (restic on n1, requiring postgres-database from
// postgres there); no input is read key by key per composition. A placement and a
// want compose the key; the providers' outcomes (the vault's entry for the credential, postgres's own have) turn it // want compose the key; the providers' outcomes (the vault's entry for the credential, postgres's own have) turn it
// provided; restic leaving the node makes it retiring and raises the ask. // provided; restic leaving the node makes it retiring and raises the ask.
@@ -19,10 +20,12 @@ import (
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/compose/frombus/frombustest" "git.novox.be/novox/mesh-sdk/go/compose/frombus/frombustest"
"git.novox.be/novox/mesh-sdk/go/compose/fromruntime"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions" "git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-sdk/go/testbus" "git.novox.be/novox/mesh-sdk/go/testbus"
@@ -166,10 +169,101 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) {
if err := s.Put(WantsRead, "want."+node+".restic", map[string]any{"v": 1, "fp": fp, "self": true}); err != nil { if err := s.Put(WantsRead, "want."+node+".restic", map[string]any{"v": 1, "fp": fp, "self": true}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// The rest of the mesh on the bus as its writers keep it: n1's placement, its record in its have, the builds.
putPlacement := func() {
raw, err := delivery.EncodePlacement(mesh.Placement[node])
if err != nil {
t.Fatal(err)
}
if err := s.Put(PlacementRead, "placement."+node, json.RawMessage(raw)); err != nil {
t.Fatal(err)
}
}
putPlacement()
for _, n := range []string{node, "n2"} { // n2, where the vault runs, has its record too
rec := mesh.Record[node]
rec.Node = n
recRaw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
if err != nil {
t.Fatal(err)
}
if err := s.Put(HaveRead, mustKey(delivery.HaveNodeKey(n)), json.RawMessage(recRaw)); err != nil {
t.Fatal(err)
}
}
for at, b := range mesh.Builds {
module, fp, _ := strings.Cut(at, " ")
raw, err := delivery.EncodeBuilderBuild(*b)
if err != nil {
t.Fatal(err)
}
if err := s.Put(BuildsRead, mustKey(delivery.BuilderBuildKey(module, fp)), json.RawMessage(raw)); err != nil {
t.Fatal(err)
}
}
// The watched copy over the real buckets: each state by one watch, a build and a manifest by key.
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
kv, err := s.kv(state)
if err != nil {
return err
}
w, err := kv.WatchAll(ctx)
if err != nil {
return err
}
hand := func(e jetstream.KeyValueEntry) {
op := stdio.OpPut
if e.Operation() != jetstream.KeyValuePut {
op = stdio.OpDelete
}
_ = handle(stdio.StateChange{State: state, Key: e.Key(), Op: op, Value: e.Value(), Revision: e.Revision()})
}
for e := range w.Updates() {
if e == nil { // the current values are all handed over
break
}
hand(e)
}
go func() {
for e := range w.Updates() {
hand(e)
}
}()
return nil
}, func(state, key string) ([]byte, bool, error) {
e, err := s.Get(state, key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
})
// settled waits until the copy holds a key as the bus does: in the mesh the holder is woken by that same watch.
settled := func(state, key string) {
t.Helper()
for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(10 * time.Millisecond) {
e, _ := s.Get(state, key)
v, found, _ := ws.Get(state, key)
if (e == nil && !found) || (e != nil && found && string(e.Value) == string(v)) {
return
}
if time.Now().After(deadline) {
t.Fatalf("the copy of %s never took %s", state, key)
}
}
}
fetch := func(_ context.Context, url string) ([]byte, error) {
for digest, body := range mesh.Archives {
if strings.HasSuffix(url, digest) {
return body, nil
}
}
return nil, errors.New("no archive at " + url)
}
reader := frombus.StatesReader(fromruntime.Watched{WatchedStates: ws}, fetch)
a := &asked{} a := &asked{}
h := &Holder{Store: s, Asks: a, Now: time.Now, Logf: t.Logf, h := &Holder{Store: Watching(s, ws), Asks: a, Now: time.Now, Logf: t.Logf,
Compose: func(ctx context.Context, node string, keptAt map[string]string) (map[string]catalogue.Plan, map[string]string, error) { Compose: func(ctx context.Context, node string, keptAt map[string]string) (map[string]catalogue.Plan, map[string]string, error) {
return frombus.PlansHeld(ctx, mesh, frombus.Mesh{}, frombus.Scope{Node: node, KeptAt: keptAt}) return frombus.PlansHeld(ctx, reader, frombus.Mesh{}, frombus.Scope{Node: node, KeptAt: keptAt})
}} }}
state := func(key string) provisions.Provision { state := func(key string) provisions.Provision {
e, _ := s.Get(ProvisionsState, key) e, _ := s.Get(ProvisionsState, key)
@@ -199,6 +293,7 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) {
"health": "ok"}); err != nil { "health": "ok"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
settled(HaveRead, node+".postgres")
for i := 0; i < 2; i++ { // the credential judged first, then what depends on it for i := 0; i < 2; i++ { // the credential judged first, then what depends on it
if _, err := h.ComposeNode(ctx, node); err != nil { if _, err := h.ComposeNode(ctx, node); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -217,9 +312,12 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) {
} }
p.Modules = kept p.Modules = kept
mesh.Placement[node] = p mesh.Placement[node] = p
putPlacement()
if err := s.Delete(WantsRead, "want."+node+".restic"); err != nil { if err := s.Delete(WantsRead, "want."+node+".restic"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
settled(PlacementRead, "placement."+node)
settled(WantsRead, "want."+node+".restic")
if _, err := h.ComposeNode(ctx, node); err != nil { if _, err := h.ComposeNode(ctx, node); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -12,6 +12,7 @@ import (
"testing" "testing"
"time" "time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
@@ -391,15 +392,60 @@ func TestAModuleHeldWithoutAWantIsComposedAtTheBuildItRuns(t *testing.T) {
} }
return nil, nil, nil return nil, nil, nil
} }
c := m.compose(t, "novox") // ADR 0580 clause 2: the reads come from one watched copy, each state watched once and none read key by key from the
if keptAt["nats"] != fp || len(keptAt) != 1 { // bus, and a change the copy takes wakes the node it touches (a resolved value its node).
t.Fatalf("composed at %v, want nats at the build it runs and nothing of another node", keptAt) bus := &readsOf{Store: m.s, read: map[string]int{}}
handles, watches := map[string]func(stdio.StateChange) error{}, map[string]int{}
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
watches[state]++
handles[state] = handle
keys, _ := m.s.Keys(state)
for _, k := range keys {
e, _ := m.s.Get(state, k)
_ = handle(stdio.StateChange{Key: k, Op: stdio.OpPut, Value: e.Value, Current: true})
}
return nil
}, func(string, string) ([]byte, bool, error) { return nil, false, nil })
m.h.Store = Watching(bus, ws)
wk := &work{pending: map[string]bool{}, wake: make(chan struct{}, 1)}
for _, r := range []string{ResolvedRead, HaveRead} {
ws.Follow(r, func(c stdio.StateChange) error { wk.add(NodesOfNodeKey(c.Key)...); return nil })
} }
if !slices.Contains(c.Plans, "novox.nats") { for i := range 2 {
t.Fatalf("no plan written for a module held without a want: %+v", c) c := m.compose(t, "novox")
if keptAt["nats"] != fp || len(keptAt) != 1 {
t.Fatalf("composed at %v, want nats at the build it runs and nothing of another node", keptAt)
}
if i == 0 && !slices.Contains(c.Plans, "novox.nats") {
t.Fatalf("no plan written for a module held without a want: %+v", c)
}
}
for _, r := range WatchedReads {
if bus.read[r] != 0 || watches[r] > 1 {
t.Errorf("%s was read %d times from the bus and watched %d times", r, bus.read[r], watches[r])
}
}
// The current haves queued their nodes when watched; a later change of a resolved value or a have queues its node.
wk.pending = map[string]bool{}
_ = handles[ResolvedRead](stdio.StateChange{Key: "g14.dunst.font-size", Op: stdio.OpPut, Value: []byte("13")})
_ = handles[HaveRead](stdio.StateChange{Key: "ace.restic", Op: stdio.OpDelete})
if !wk.pending["g14"] || !wk.pending["ace"] || len(wk.pending) != 2 {
t.Errorf("a resolved value's and a have's change queued %v, want g14 and ace", wk.pending)
} }
} }
// readsOf counts the reads that reach the bus, by state.
type readsOf struct {
Store
read map[string]int
}
func (r *readsOf) Get(state, key string) (*Entry, error) {
r.read[state]++
return r.Store.Get(state, key)
}
func (r *readsOf) Keys(state string) ([]string, error) { r.read[state]++; return r.Store.Keys(state) }
// The holder holds no ticker: it composes on change events alone (ADR 0570 clause 2; issue 1954 H1). // The holder holds no ticker: it composes on change events alone (ADR 0570 clause 2; issue 1954 H1).
func TestTheHolderHoldsNoTimer(t *testing.T) { func TestTheHolderHoldsNoTimer(t *testing.T) {
files, _ := filepath.Glob("*.go") files, _ := filepath.Glob("*.go")
@@ -105,13 +105,17 @@ func (w *work) run(h *Holder) {
func main() { func main() {
s := runtimeStore{} s := runtimeStore{}
h := &Holder{Store: s, Compose: FromBus(), Asks: runtimeAsks{}, Now: time.Now, Logf: logf} ws := stdio.NewWatchedStates() // one copy of what it reads, each state by its watch (ADR 0580 clause 2)
h := &Holder{Store: Watching(s, ws), Compose: FromWatched(ws), Asks: runtimeAsks{}, Now: time.Now, Logf: logf}
w := &work{pending: map[string]bool{}, wake: make(chan struct{}, 1)} w := &work{pending: map[string]bool{}, wake: make(chan struct{}, 1)}
go w.run(h) go w.run(h)
go starting(h) go starting(h)
go stdio.State(PlacementRead).Follow("", func(c stdio.StateChange) error { w.add(NodesOfPlacement(c.Key)...); return nil }) // Each watch's handler runs after the copy took its change, so a composition it wakes reads the copy that change made.
go stdio.State(WantsRead).Follow("", func(c stdio.StateChange) error { w.add(NodesOfWant(c.Key)...); return nil }) go ws.Follow(PlacementRead, func(c stdio.StateChange) error { w.add(NodesOfPlacement(c.Key)...); return nil })
go stdio.State(ModulesRead).Follow("", func(c stdio.StateChange) error { go ws.Follow(WantsRead, func(c stdio.StateChange) error { w.add(NodesOfWant(c.Key)...); return nil })
go ws.Follow(ResolvedRead, func(c stdio.StateChange) error { w.add(NodesOfNodeKey(c.Key)...); return nil })
go ws.Follow(HaveRead, func(c stdio.StateChange) error { w.add(NodesOfNodeKey(c.Key)...); return nil })
go ws.Follow(ModulesRead, func(c stdio.StateChange) error {
if module, err := delivery.ParseModulesKey(c.Key); err == nil { if module, err := delivery.ParseModulesKey(c.Key); err == nil {
w.add(h.NodesOfModule(module)...) w.add(h.NodesOfModule(module)...)
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-provisioner
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -1
View File
@@ -30,7 +30,7 @@ that seat (ADR 0207).
`package.changed`. A list over the bus's cap on a value is not written: the key keeps its last value and `package.changed`. A list over the bus's cap on a value is not written: the key keeps its last value and
`<node>.not-current` says the size and since when, which the health says too. `pacman_packages`, `<node>.not-current` says the size and since when, which the health says too. `pacman_packages`,
`pacman_updates`, `pacman_installed` and `pacman_foreign` answer every node from these states, and what no `pacman_updates`, `pacman_installed` and `pacman_foreign` answer every node from these states, and what no
module declares from the controller's `declared-packages`, without asking a machine. module declares from the node-engine's `packages`, else the controller's `declared-packages` until its lease is written, without asking a machine.
- **Upgrades as runs, one node at a time (ADR 0508).** Every full upgrade and every rollback is a run in the - **Upgrades as runs, one node at a time (ADR 0508).** Every full upgrade and every rollback is a run in the
state `runs` at `<node>.<run>`, written at each step and emitted as it: the package record taken and read back state `runs` at `<node>.<run>`, written at each step and emitted as it: the package record taken and read back
(`records`, kept `record-days`), the DKMS gate before the transaction (a kernel an installed DKMS module's own (`records`, kept `record-days`), the DKMS gate before the transaction (a kernel an installed DKMS module's own
@@ -102,7 +102,7 @@ func TestTheKeeperPutsTheNodesPackagesOnARealBusAndAnotherInstanceReadsThem(t *t
} }
buckets[name] = kv buckets[name] = kv
} }
declared, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-controller_declared-packages"}) declared, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "node-engine_packages"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -133,7 +133,7 @@ func TestTheKeeperDeclaresTheModulesStatesAndEvents(t *testing.T) {
} }
// The verbs read which packages each node's modules declare; the process reads no other module's state // The verbs read which packages each node's modules declare; the process reads no other module's state
// (ADR 0507 §5, ADR 0436 §1). // (ADR 0507 §5, ADR 0436 §1).
if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{"mesh-controller.declared-packages"}) { if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{"node-engine.packages", "mesh-controller.declared-packages"}) {
t.Errorf("reads: %v", got) t.Errorf("reads: %v", got)
} }
} }
+78 -36
View File
@@ -1,7 +1,7 @@
package main package main
// The package view (novox/hq ADR 0507 §6, §7; acceptance 1840/AC6): verbs that read the module's states and the // The package view (novox/hq ADR 0507 §6, §7; acceptance 1840/AC6): verbs that read the module's states and the
// controller's `declared-packages`, so any instance answers for every node and no machine is asked. A node that is // node-engine's `packages`, so any instance answers for every node and no machine is asked. A node that is
// off keeps its last values, shown with the time they describe; a node whose packages are over the bus's cap is // off keeps its last values, shown with the time they describe; a node whose packages are over the bus's cap is
// marked not current. A foreign package is listed apart in every answer. // marked not current. A foreign package is listed apart in every answer.
@@ -11,21 +11,27 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// DeclaredPackagesState is the controller's state of the packages each node's modules declare (ADR 0507 §5), // DeclaredPackagesState is the node-engine's state of the packages its node's modules declare, one key per node,
// read through the manifest's `reads`. // written by that node's engine alone (ADR 0565 clause 6, which moved ADR 0507 §5's mirror off the controller), read
const DeclaredPackagesState = "mesh-controller.declared-packages" // through the manifest's `reads`.
const DeclaredPackagesState = SourceEngine
// declaredValue is a node's value of `declared-packages`, as the controller writes it. // ControllerDeclaredState is the controller's mirror of the same packages (ADR 0507 §5), the per-node fallback for a node
type declaredValue struct { // whose engine has written no key yet, until the controller's lease is written away (ADR 0565 clause 6).
Node string `json:"node"` const ControllerDeclaredState = "mesh-controller.declared-packages"
Packages []struct {
Package string `json:"package"` // The sources a node's declaration is read from, said in the answer.
Modules []string `json:"modules"` const (
Absent bool `json:"absent"` SourceEngine = "node-engine.packages"
} `json:"packages"` SourceController = "mesh-controller.declared-packages"
} )
// declaredValue is a node's key of `packages`, in mesh-sdk's layout (the controller's mirror unchanged).
type declaredValue = delivery.DeclaredPackages
// nodeInstalled is one node's key of `installed` with its not-current mark. // nodeInstalled is one node's key of `installed` with its not-current mark.
type nodeInstalled struct { type nodeInstalled struct {
@@ -111,28 +117,53 @@ func (m *Machine) installedOn(node string) ([]nodeInstalled, error) {
return out, nil return out, nil
} }
// declaredOn is what each node's modules declare present, by node and package, with the declaring modules; a // declaredOn is what each node's modules declare present, by node and package, with the declaring modules, and the source
// node the controller holds nothing for is missing. // that answered for each node: its engine's key, else the controller's mirror; a node neither holds is missing. When the
func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, error) { // engine's state cannot be read at all (its bucket is not on the bus yet, or the read is refused) the mirror answers for
r := m.States(DeclaredPackagesState) // every node and the engine's error is returned as a word to say beside the sources; it is an error only when the mirror
// cannot be read either (ADR 0565 clause 6).
func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, map[string]string, string, error) {
out := map[string]map[string][]string{} out := map[string]map[string][]string{}
for _, n := range nodes { from := map[string]string{}
var v declaredValue engineErr := ""
found, err := readValue(r, n, &v) for _, source := range []string{SourceEngine, SourceController} {
if err != nil { r := m.States(source)
return nil, err read := map[string]map[string][]string{}
} readFrom := map[string]string{}
if !found { var failed error
continue for _, n := range nodes {
} if from[n] != "" {
out[n] = map[string][]string{} continue
for _, p := range v.Packages { }
if !p.Absent { var v declaredValue
out[n][p.Package] = p.Modules found, err := readValue(r, n, &v)
if err != nil {
failed = err
break
}
if !found {
continue
}
readFrom[n] = source
read[n] = map[string][]string{}
for _, p := range v.Packages {
if !p.Absent {
read[n][p.Package] = p.Modules
}
} }
} }
if failed != nil {
if source == SourceEngine {
engineErr = SourceEngine + " cannot be read: " + failed.Error()
continue
}
return nil, nil, "", failed
}
for n, d := range read {
out[n], from[n] = d, readFrom[n]
}
} }
return out, nil return out, from, engineErr, nil
} }
// NodeView is one node in the view. // NodeView is one node in the view.
@@ -146,9 +177,11 @@ type NodeView struct {
Current bool `json:"current"` Current bool `json:"current"`
NotCurrent *NotCurrent `json:"not_current,omitempty"` NotCurrent *NotCurrent `json:"not_current,omitempty"`
// Undeclared is the packages installed explicitly from a repository that no module assigned to the node // Undeclared is the packages installed explicitly from a repository that no module assigned to the node
// declares; null when the controller holds no declaration of the node. // declares; null when neither the node-engine nor the controller's mirror holds a declaration of the node.
Undeclared []PackageVersion `json:"undeclared"` Undeclared []PackageVersion `json:"undeclared"`
Foreign []PackageVersion `json:"foreign"` Foreign []PackageVersion `json:"foreign"`
// DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one.
DeclaredFrom string `json:"declared_from,omitempty"`
} }
// Spread is one package across the nodes: its version on each node that has it. // Spread is one package across the nodes: its version on each node that has it.
@@ -167,6 +200,8 @@ type PackageOnNode struct {
Foreign bool `json:"foreign,omitempty"` Foreign bool `json:"foreign,omitempty"`
DeclaredBy []string `json:"declared_by"` DeclaredBy []string `json:"declared_by"`
Current bool `json:"current"` Current bool `json:"current"`
// DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one.
DeclaredFrom string `json:"declared_from,omitempty"`
} }
// Packages is pacman_packages: one package on every node, or the whole view: per node its counts, its explicit // Packages is pacman_packages: one package on every node, or the whole view: per node its counts, its explicit
@@ -181,14 +216,14 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
for _, ni := range all { for _, ni := range all {
nodes = append(nodes, ni.Node) nodes = append(nodes, ni.Node)
} }
declared, err := m.declaredOn(nodes) declared, sources, engineErr, err := m.declaredOn(nodes)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if name != "" { if name != "" {
on := []PackageOnNode{} on := []PackageOnNode{}
for _, ni := range all { for _, ni := range all {
p := PackageOnNode{Node: ni.Node, Current: ni.NotCurrent == nil, DeclaredBy: declared[ni.Node][name]} p := PackageOnNode{Node: ni.Node, Current: ni.NotCurrent == nil, DeclaredBy: declared[ni.Node][name], DeclaredFrom: sources[ni.Node]}
if p.DeclaredBy == nil { if p.DeclaredBy == nil {
p.DeclaredBy = []string{} p.DeclaredBy = []string{}
} }
@@ -203,13 +238,17 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
} }
on = append(on, p) on = append(on, p)
} }
return map[string]any{"package": name, "nodes": on}, nil answer := map[string]any{"package": name, "nodes": on}
if engineErr != "" {
answer["declared_engine_error"] = engineErr
}
return answer, nil
} }
views := []NodeView{} views := []NodeView{}
versions := map[string]map[string]string{} versions := map[string]map[string]string{}
for _, ni := range all { for _, ni := range all {
v := NodeView{Node: ni.Node, At: ni.Value.At, Packages: len(ni.Value.Packages), Current: ni.NotCurrent == nil, v := NodeView{Node: ni.Node, At: ni.Value.At, Packages: len(ni.Value.Packages), Current: ni.NotCurrent == nil,
NotCurrent: ni.NotCurrent, Foreign: []PackageVersion{}} NotCurrent: ni.NotCurrent, Foreign: []PackageVersion{}, DeclaredFrom: sources[ni.Node]}
d, known := declared[ni.Node] d, known := declared[ni.Node]
if known { if known {
v.Undeclared = []PackageVersion{} v.Undeclared = []PackageVersion{}
@@ -252,6 +291,9 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
} }
} }
out := map[string]any{"nodes": views} out := map[string]any{"nodes": views}
if engineErr != "" {
out["declared_engine_error"] = engineErr
}
for key, list := range map[string][]Spread{"differing": differing, "some_nodes": some} { for key, list := range map[string][]Spread{"differing": differing, "some_nodes": some} {
b := bound(key, list, limit) b := bound(key, list, limit)
out[key] = b[key] out[key] = b[key]
+54 -3
View File
@@ -1,19 +1,22 @@
package main package main
// The package view over the bus (novox/hq ADR 0507 §6, §7; acceptance 1840/AC6): two nodes' `installed` keys and // The package view over the bus (novox/hq ADR 0507 §6, §7; acceptance 1840/AC6): two nodes' `installed` keys and
// their `declared-packages`, answered without asking any machine. // the node-engine's `packages`, answered without asking any machine.
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
stdio "git.novox.be/novox/mesh-sdk/go"
) )
// viewStates is two nodes' packages on the bus: node-a current and declared, node-b over the cap since a while, // viewStates is two nodes' packages on the bus: node-a current and declared, node-b over the cap since a while,
// with a foreign package, and no declaration the controller holds. // with a foreign package, and no declaration the node-engine published.
func viewStates(t *testing.T) (*fakeStates, *Machine) { func viewStates(t *testing.T) (*fakeStates, *Machine) {
t.Helper() t.Helper()
states := &fakeStates{} states := &fakeStates{}
@@ -40,7 +43,7 @@ func viewStates(t *testing.T) (*fakeStates, *Machine) {
} }
func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) { func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) {
_, m := viewStates(t) states, m := viewStates(t)
r, err := m.Packages("zsh", 500) r, err := m.Packages("zsh", 500)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -50,6 +53,47 @@ func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) {
t.Fatalf("%+v", on) t.Fatalf("%+v", on)
} }
a, b := on[0], on[1] a, b := on[0], on[1]
if a.DeclaredFrom != SourceEngine || b.DeclaredFrom != "" {
t.Errorf("sources: %q, %q", a.DeclaredFrom, b.DeclaredFrom)
}
// A node whose engine has written no key reads the controller's mirror, said so; a node with both reads its engine's.
var legacy, stale declaredValue
json.Unmarshal([]byte(`{"node":"node-b","packages":[{"package":"zsh","modules":["legacy"],"absent":false}]}`), &legacy)
json.Unmarshal([]byte(`{"node":"node-a","packages":[{"package":"zsh","modules":["stale"],"absent":false}]}`), &stale)
mirror := states.store(ControllerDeclaredState)
mirror.Put("node-b", legacy)
mirror.Put("node-a", stale)
r2, _ := m.Packages("zsh", 500)
on2 := r2["nodes"].([]PackageOnNode)
if on2[0].DeclaredFrom != SourceEngine || strings.Join(on2[0].DeclaredBy, ",") != "login,shell" ||
on2[1].DeclaredFrom != SourceController || strings.Join(on2[1].DeclaredBy, ",") != "legacy" {
t.Errorf("fallback: %+v", on2)
}
// The engine's state cannot be read (its bucket is not on the bus yet): the mirror answers for every node, and the
// answer says why beside declared_from; a mirror that cannot be read either is an error.
read := m.States
m.States = func(name string) StateReader {
if name == SourceEngine {
return failingReader{errors.New("is issued and its bucket is not on the bus yet")}
}
return read(name)
}
r3, err3 := m.Packages("zsh", 500)
on3, _ := r3["nodes"].([]PackageOnNode)
if err3 != nil || len(on3) != 2 || on3[0].DeclaredFrom != SourceController || strings.Join(on3[0].DeclaredBy, ",") != "stale" ||
on3[1].DeclaredFrom != SourceController || !strings.Contains(fmt.Sprint(r3["declared_engine_error"]), "not on the bus yet") {
t.Errorf("engine unreadable: %+v %v", r3, err3)
}
m.States = func(name string) StateReader {
if name == SourceEngine || name == SourceController {
return failingReader{errors.New("refused")}
}
return read(name)
}
if _, err := m.Packages("zsh", 500); err == nil {
t.Error("neither state could be read and no error was said")
}
m.States = read
if a.Node != "node-a" || !a.Installed || a.Version != "5.9-2" || !a.Explicit || a.Repository != "extra" || if a.Node != "node-a" || !a.Installed || a.Version != "5.9-2" || !a.Explicit || a.Repository != "extra" ||
strings.Join(a.DeclaredBy, ",") != "login,shell" || !a.Current { strings.Join(a.DeclaredBy, ",") != "login,shell" || !a.Current {
t.Errorf("node-a: %+v", a) t.Errorf("node-a: %+v", a)
@@ -158,3 +202,10 @@ func TestANodeArgumentIsOneKeyToken(t *testing.T) {
t.Errorf("%q %v", n, err) t.Errorf("%q %v", n, err)
} }
} }
// failingReader is a state that cannot be read.
type failingReader struct{ err error }
func (f failingReader) Get(string) (*stdio.StateEntry, error) { return nil, f.err }
func (f failingReader) Keys() ([]string, error) { return nil, f.err }
func (f failingReader) History(string) ([]stdio.StateRevision, error) { return nil, f.err }
+1 -1
View File
@@ -3,7 +3,7 @@ module pacman
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1
View File
@@ -12,6 +12,7 @@
} }
], ],
"reads": [ "reads": [
"node-engine.packages",
"mesh-controller.declared-packages" "mesh-controller.declared-packages"
], ],
"uses": [ "uses": [