Keep the builder's history as its own log that readers declare and follow, the memberships as the grants part's state, and refuse a module's own stream, so every module's data is in the bus's predefined forms and readable by its tools (issue 1955, ADR 0571 clauses 1, 4, 5, 7)

This commit is contained in:
2026-10-29 23:28:21 +01:00
parent 196c5b6af2
commit bb993dd606
16 changed files with 695 additions and 51 deletions
+37 -5
View File
@@ -58,9 +58,13 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
}
// 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"}}},
"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)
@@ -68,9 +72,19 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
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) != 3 {
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)
}
@@ -79,7 +93,8 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
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.Artifacts = BuildEndedEvent, at.Add(3*time.Minute), "done", []BuiltDigest{{Name: "image", Digest: "sha256:1"}}
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)
@@ -91,7 +106,24 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
if d, ok := BuildDuration(start, end); !ok || d != 3*time.Minute {
t.Errorf("the build took %s, %v", d, ok)
}
for _, edit := range []func(*BuildLogEntry){func(e *BuildLogEntry) { e.Outcome = "" }, func(e *BuildLogEntry) { e.Commit = "" }} {
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 {
+121 -28
View File
@@ -5,6 +5,8 @@ import (
"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).
@@ -24,12 +26,15 @@ type SeatHolder struct {
}
// 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.
// (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"`
}
@@ -49,7 +54,8 @@ func SeatHoldersKey(seat, node string) (string, error) {
}
// 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. Deterministic, so a writer writes only a key
// 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{}
@@ -64,12 +70,17 @@ func SeatHoldersOf(placements map[string]Placement) map[string]SeatHolders {
if h.Scope == "node" {
node = n
}
key, err := SeatHoldersKey(h.Claim, node)
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 = Version, h.Claim, h.Scope, node
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
}
@@ -110,39 +121,89 @@ func HoldersOf(keys map[string][]byte, seat, node string) ([]SeatHolder, error)
return nil, nil
}
// The builder's log (clause 1): a stream of its own, `log`, one entry when a build of a fingerprint starts and one when
// it ends, kept 30 days.
// 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 (
BuilderLogStream = "log"
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"
)
// BuiltDigest is one artifact a build ended with, by its name and digest.
type BuiltDigest struct {
Name string `json:"name"`
Digest string `json:"digest"`
// 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 and the commit), the build node and the time; at the end, the outcome
// and each artifact's digest. The repository, directory and commit are what the indexer's PathOf, the outside-built
// registrations and the assigner's running commits read (the lead, 2026-10-29).
// 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"`
Node string `json:"node"`
At time.Time `json:"at"`
Outcome string `json:"outcome,omitempty"`
Artifacts []BuiltDigest `json:"artifacts,omitempty"`
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.
@@ -155,8 +216,8 @@ func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) {
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.Artifacts) > 0):
return nil, fmt.Errorf("delivery: a build's start names no outcome and no artifact")
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)
}
@@ -182,3 +243,35 @@ func BuildDuration(start, end BuildLogEntry) (time.Duration, bool) {
}
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
}