Compare commits

..
2 Commits
Author SHA1 Message Date
jochen fe2ee39365 Let the built bus module register with its events: the resolved manifest keeps bundles, not the build (hq issue 270)
mesh/merge-gate out of date: judged against trunk 04730b54a8f2, the trunk is now 535c7641ac41; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 04730b54a8f2, the trunk is now 535c7641ac41; judged again on a recheck or when it is merged
mesh/delivery checking
#326 allowed a bus module to emit when its build makes a tools bundle, but
registration reads the resolved manifest, which drops the build and keeps the
bundles it made. The merge gate found nats not registrable on that (mesh-catalog
#293); the bundles now count too.
2026-10-14 09:26:01 +02:00
jochen d24132d54f Raise the bus module's watch when it falls silent or says it cannot see (hq issue 270, review 274)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head d24132d54f01 merged into trunk 04730b54a8f2 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head d24132d54f01 merged into trunk 04730b54a8f2 — pass: its merge-check.sh passed
A dead watch of slow consumers reads as none. The watch now beats after every
look; S9 reads the newest heartbeat and raises bus-watch-dead when it is older
than two minutes or says the watch is not healthy, on the bus's machine. A
stream with no heartbeat at all is a bus module older than its watch.
2026-10-14 09:19:57 +02:00
103 changed files with 543 additions and 7714 deletions
+14 -39
View File
@@ -26,7 +26,6 @@ import (
"os/signal"
"sort"
"strings"
"sync"
"syscall"
"time"
@@ -273,12 +272,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
// role's own events under the build's id (novox/hq ADR 0157) — so whoever asked, and anybody
// watching, reads the same lines this container's log holds, live, and after the fact from the
// stream. Said first, before anything runs, so a build that hangs is one that visibly started.
// One line at a time: a check's builds for its merge say their lines while its tests say theirs (novox/hq
// issue 716).
var saying sync.Mutex
say := func(step, message string) {
saying.Lock()
defer saying.Unlock()
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
work.Say(step, message)
if mine != nil && step != "run" && step != "output" {
@@ -319,25 +313,20 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
registry = r.Address
}
spec := checkSpecOf(request)
// The judge cache the build agent declares as its own data, never in the workspace: only judge builds and
// mesh-lab's replays mount it, never a check's containers, so no pull request's script can write what a judge
// is built with or kept as, or what the replays run (novox/hq ADR 0358 §6, issue 709).
spec.JudgeCache = judgeCache()
// What the merge would move, built by the check for the walk to take (novox/hq ADR 0350 §1): into the
// scratch namespace. **Never with the package registry's credential**: this is code nobody has approved
// yet, and a recipe could read the credential it is handed (issue 462). A module that resolves packages
// at build time has no reuse key anyway, so nothing is lost by refusing it here.
if isRegistry && len(spec.Builds) > 0 {
spec.BuildWith = &builder.CheckBuilding{Publish: publisher, Scratch: registryHere, Held: request.Held,
Running: request.Check.Running, Seats: request.Seats, Named: namedOf(request.Check.Named)}
Seats: request.Seats}
}
var v builder.CheckVerdict
v, err = builder.Check(building, builder.Command, spec, workspace, registry, forgeFrom(), say)
if err == nil {
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo),
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside, Built: checkBuiltOf(v.Built),
Release: v.Release, JudgedBy: v.JudgedBy, BesideAt: v.BesideAt, Validator: v.Validator}
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside, Built: checkBuiltOf(v.Built)}
// What each of a group's heads was judged as (novox/hq ADR 0323 §6).
for _, m := range v.Members {
result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo,
@@ -363,7 +352,15 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
reuse.Pulls = append(reuse.Pulls, builder.PullRef{Owner: p.Owner, Repo: p.Repo, Number: p.Number})
}
// Only what a verdict names is taken (ADR 0350 §2).
reuse.Named = namedOf(request.Reuse.Builds)
for _, b := range request.Reuse.Builds {
named := builder.NamedBuild{Pull: builder.PullRef{Owner: b.Pull.Owner, Repo: b.Pull.Repo,
Number: b.Pull.Number}, Check: b.Check, Module: b.Built.Module, Key: b.Built.Key,
Digests: map[string]string{}}
for _, m := range b.Built.Made {
named.Digests[m.Name] = builder.DigestOf(m.Reference)
}
reuse.Named = append(reuse.Named, named)
}
built, err = builder.BuildReusing(building, builder.Labelled(builder.Command, request.ID), publisher, reuse,
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
forgeFrom(), say, request.Seats)
@@ -467,9 +464,9 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
Owner: c.Owner, Repo: c.Repo, Number: c.Number, Paths: c.Paths, Beside: map[string]builder.Beside{},
Modules: c.Modules, New: c.New, Manifests: c.Manifests, Judge: c.Judge, Base: c.Base,
Toolchain: builder.ToolchainOf(request.Held), Toolchains: builder.ToolchainsOf(request.Held),
Tests: c.Tests, StoreOnly: c.StoreOnly, Release: c.Release}
Tests: c.Tests, StoreOnly: c.StoreOnly}
for dir, b := range c.Beside {
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref, Else: b.Else}
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref}
}
for _, b := range c.Builds {
spec.Builds = append(spec.Builds, builder.CheckBuildSpec{Module: b.Module, Repository: b.Repository, Path: b.Path,
@@ -482,11 +479,6 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
return spec
}
// judgeCache is the judge cache the build agent's manifest declares (MESH_JUDGE_CACHE, novox/hq ADR 0358 §6): a
// directory mounted at the same path on the host, so a judge build's mounts name what the agent sees. Empty when the
// agent declares none, and every judge is then built cold under its check's root.
func judgeCache() string { return os.Getenv("MESH_JUDGE_CACHE") }
// triedOf is a try's outcome as its log line says it (novox/hq issue 686): the repository's own check's verdict,
// or the check's where that did not run — a head that does not merge — and the lines naming what failed.
func triedOf(v builder.CheckVerdict) link.Tried {
@@ -504,28 +496,11 @@ func triedOf(v builder.CheckVerdict) link.Tried {
return t
}
// namedOf are the builds the seat's verdicts name, as the builder believes a scratch record by them (novox/hq ADR
// 0350 §2, issue 703): each module's key with every artifact's digest.
func namedOf(builds []link.CheckedBuild) []builder.NamedBuild {
var out []builder.NamedBuild
for _, b := range builds {
named := builder.NamedBuild{Pull: builder.PullRef{Owner: b.Pull.Owner, Repo: b.Pull.Repo,
Number: b.Pull.Number}, Check: b.Check, Module: b.Built.Module, Key: b.Built.Key,
Digests: map[string]string{}}
for _, m := range b.Built.Made {
named.Digests[m.Name] = builder.DigestOf(m.Reference)
}
out = append(out, named)
}
return out
}
// checkBuiltOf is a check's builds for its merge as the outcome carries them (novox/hq ADR 0350 §1).
func checkBuiltOf(built []builder.CheckBuilt) []link.CheckBuilt {
var out []link.CheckBuilt
for _, b := range built {
c := link.CheckBuilt{Module: b.Module, Key: b.Key, NotBuilt: b.NotBuilt, Took: b.Took.Round(time.Second).String(),
Kept: b.Kept, Same: b.Same, SameAs: b.SameAs}
c := link.CheckBuilt{Module: b.Module, Key: b.Key, NotBuilt: b.NotBuilt, Took: b.Took.Round(time.Second).String()}
for _, m := range b.Made {
c.Made = append(c.Made, link.MadeArtifact{Name: m.Name, Kind: m.Kind, Reference: m.Reference})
}
-27
View File
@@ -1,27 +0,0 @@
package main
import (
"testing"
"github.com/novox/mesh-controller/internal/link"
)
// **A pull request into a release branch reaches the check as one** (novox/hq ADR 0358): the build seat hands the
// check the release branch and, beside it, the trunk each repository is read at when it has no branch of that name;
// and it hands the check the judge cache the build agent declares (MESH_JUDGE_CACHE), never a path of its own.
func TestTheBuildSeatHandsTheCheckItsReleaseBranch(t *testing.T) {
spec := checkSpecOf(link.BuildRequest{ID: "b", Repository: "r", Ref: "abc", Check: &link.CheckRequest{
Owner: "novox", Repo: "mesh-catalog", Base: "feat/57", Release: "feat/57",
Beside: map[string]link.CheckedOut{"mesh-host": {Repository: "h", Ref: "feat/57", Else: "main"}}}})
if spec.Release != "feat/57" || spec.Beside["mesh-host"].Ref != "feat/57" || spec.Beside["mesh-host"].Else != "main" {
t.Fatalf("the check was handed release %q and mesh-host %+v", spec.Release, spec.Beside["mesh-host"])
}
t.Setenv("MESH_JUDGE_CACHE", "/var/lib/mesh/build-agent/judge-cache")
if got := judgeCache(); got != "/var/lib/mesh/build-agent/judge-cache" {
t.Fatalf("the judge cache the manifest declares is read as %q", got)
}
t.Setenv("MESH_JUDGE_CACHE", "")
if got := judgeCache(); got != "" {
t.Fatalf("with no judge cache declared, %q is used", got)
}
}
+2 -5
View File
@@ -26,11 +26,8 @@ import (
//
// - **The serving controller** takes it before it does anything else — before it asserts the bus's
// objects, which are the controller's to write — waiting while another holds it, and renews it.
// Another holder seen, or the key's age passed with no renewal made, is the lease lost: the gate
// closes at once and the process exits, so its service manager restarts it as a candidate (serve,
// in push.go). A renewal the bus does not answer in time is not a loss (novox/hq issue 477): the
// lease is kept, no new act starts once half the key's age passed unrenewed, and the next renewal
// made opens the gate again.
// A renewal refused or failed is the lease lost: the gate closes at once and the process exits, so
// its service manager restarts it as a candidate (serve, in push.go).
// - **A command run at a shell** — `push` in the installer, the lab, a person repairing a mesh whose
// controller is down (issue 201) — acts **under the holder's epoch** when a controller holds the
// lease: it is the same mesh's word, composed and sent under the store's hold of each machine like
+1 -1
View File
@@ -140,7 +140,7 @@ func TestTheRegistryTrustAndEveryImageFollowThePortTheNodeGaveTheStore(t *testin
}
// And a replay to the catalogue says where the store is now.
announced, err := following{open: open}.Announceable(ctx)
announced, err := following{open}.Announceable(ctx)
if err != nil {
t.Fatal(err)
}
+3 -8
View File
@@ -688,7 +688,7 @@ func spelledOf(m inventory.BatchedMerge) string {
func namedOf(m inventory.BatchedMerge, repository string) inventory.PlanMerge {
k := announcedOf(m)
return inventory.PlanMerge{Repository: repository, Commit: m.Commit, Number: k.Number, Title: k.Title, Moves: k.Moves,
Head: k.HeadSHA, Merged: m.Merged, Heard: m.Heard}
Merged: m.Merged, Heard: m.Heard}
}
// laterMerge says a was merged after b: by the forge's merge time, then by when each was heard.
@@ -1000,15 +1000,10 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
plan.Delivery.Merges = named
plan.Delivery.Alone = batch.Delivery != nil && batch.Delivery.Alone
plan.Delivery.Withheld = withheld
// **Left where it is, a module whose program did not change** (novox/hq issue 721): its check found it the same as
// the build the mesh holds.
for n, why := range leaveSameAsRunning(ctx, inv, &plan, moved, append(append([]inventory.Edge{}, edges...), ordered...)) {
unchanged[n] = why
}
plan.Delivery.Unchanged = unchangedLeft(unchanged, plan.Modules)
// **Its moments and its class** (novox/hq ADR 0282 decision 6): measured, never acted on.
plan.Times = walkTimesAtCut(*batch, plan, entries, now)
if len(moved) == 0 || len(plan.Modules) == 0 {
if len(moved) == 0 {
plan.State = inventory.PlanDone
plan.Tiers = [][]string{}
plan.Note = "moved nothing the mesh holds: nothing to walk"
@@ -1049,7 +1044,7 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
return nil
}
// Written and its first tier asked as one act on the plans (novox/hq issue 213), under the caller's hold.
if err := askTier(ctx, inv, &plan, plan.Tiers[plan.Tier]); err != nil {
if err := askTier(ctx, inv, &plan); err != nil {
return err
}
if err := inv.SavePlan(ctx, &plan); err != nil {
+1 -1
View File
@@ -758,7 +758,7 @@ func TestTheCatchUpKeepsWhatACutMadeHistory(t *testing.T) {
return entries, read, err
}
if err := catchUpOnMerges(ctx, time.Now(), unheardMerges{announcedList(lost), open.inventory}, catalogued,
(following{open: open}).SourceMoved, t.Logf); err != nil {
(following{open}).SourceMoved, t.Logf); err != nil {
t.Fatal(err)
}
got, _ := open.inventory.PlanByID(ctx, w.ID)
-28
View File
@@ -899,34 +899,6 @@ type answers struct {
//
// Routed through the artifact store as the network reaches it now (novox/hq 04-ISSUES/102): a
// base is recorded by digest and path, and a build machine needs something it can pull.
// runningBy is, keyed "<module>/<artifact>", each artifact of the build the mesh has registered for its module, as a
// builder reaches the artifact store (novox/hq issue 721): what a check compares its builds with. Empty when it cannot
// be read, said, and then nothing is said unchanged.
func runningBy(ctx context.Context) map[string]string {
open, err := openStores(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "could not read what this mesh runs, so the check compares nothing: %v\n", err)
// empty-on-error: said above; a check given nothing to compare with says no module unchanged, and all move
return nil
}
defer open.Close()
running, err := open.inventory.RegisteredHeld(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "could not read what this mesh runs, so the check compares nothing: %v\n", err)
// empty-on-error: said above; a check given nothing to compare with says no module unchanged, and all move
return nil
}
address, err := whereABuilderReachesTheStore(ctx, open.inventory)
if err != nil || address == "" {
return running
}
routed := make(map[string]string, len(running))
for key, reference := range running {
routed[key] = catalogue.Rerouted(reference, address)
}
return routed
}
func heldBy(ctx context.Context) map[string]string {
open, err := openStores(ctx)
if err != nil {
@@ -90,3 +90,34 @@ func TestABusModuleReadThatFailsKeepsTheControllersOwnSignals(t *testing.T) {
}
}
}
// **A dead watch is heard** (review 274): the bus module's watch silent for longer than three looks, or saying
// it cannot see, is a condition on the bus's machine — never read as no slow consumers. A stream with no
// heartbeat at all is a bus module older than its watch, and says nothing.
func TestTheBusWatchSilentOrBlindIsAConditionAndNoHeartbeatAtAllIsNone(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
look := func(b link.BusWatchBeat, heard bool) []conditions.Observation {
var out []conditions.Observation
for _, o := range watchAdvisories(&signalFacts{now: now, busWatch: b, busWatchHeard: heard}) {
if o.Kind == kindBusWatchDead {
out = append(out, o)
}
}
return out
}
if got := look(link.BusWatchBeat{}, false); len(got) != 0 {
t.Fatalf("no heartbeat ever raised %+v", got)
}
if got := look(link.BusWatchBeat{At: now.Add(-30 * time.Second), Healthy: true, Machine: "anchor"}, true); len(got) != 0 {
t.Fatalf("a healthy watch heard 30s ago raised %+v", got)
}
silent := look(link.BusWatchBeat{At: now.Add(-5 * time.Minute), Healthy: true, Machine: "anchor"}, true)
if len(silent) != 1 || silent[0].Machine != "anchor" || !strings.Contains(silent[0].Summary, "said nothing") {
t.Fatalf("a watch silent for 5 minutes raised %+v", silent)
}
blind := look(link.BusWatchBeat{At: now.Add(-10 * time.Second), Healthy: false, Machine: "anchor",
Why: "the watch of slow consumers last read the bus's closed connections 95s ago: connection refused"}, true)
if len(blind) != 1 || !strings.Contains(blind[0].Said, "connection refused") {
t.Fatalf("a watch that says it cannot see raised %+v", blind)
}
}
+1 -30
View File
@@ -60,19 +60,9 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
if err != nil {
return nil, err
}
// And every module's own stream (novox/hq ADR 0363), from the catalogue, before the consumers on it.
modStreams, err := inv.DeclaredStreams(ctx)
if err != nil {
return nil, err
}
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
var failed []error
for _, s := range broker.ModuleStreams(modStreams) {
if err := r.EnsureStream(s); err != nil {
failed = append(failed, fmt.Errorf("the module stream %s: %w", s.Name, err))
}
}
for _, s := range trafficStreams {
if err := r.EnsureStream(s); err != nil {
failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err))
@@ -83,30 +73,11 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err))
}
}
restricted := map[string]bool{}
for _, s := range modStreams {
restricted[s.Name] = s.Restricted
}
for _, c := range consumers {
if err := broker.EnsureHeard(r, c.Consumer, restricted[c.Consumer.Stream]); err != nil {
if err := r.EnsureConsumer(c.Consumer); err != nil {
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
}
}
// **And a consumer no longer granted loses what it reads through** (novox/hq ADR 0363 §6): every consumer
// on a module's stream the composition no longer asks for is removed, on this very send.
if p, prunes := r.(broker.Pruner); prunes && len(modStreams) > 0 {
var wanted []broker.Consumer
for _, c := range consumers {
wanted = append(wanted, c.Consumer)
}
removed, err := broker.PruneHeard(p, modStreams, wanted)
if err != nil {
failed = append(failed, err)
}
for _, gone := range removed {
fmt.Printf(" removed the consumer %s: its module is no longer granted that stream (novox/hq ADR 0363)\n", gone)
}
}
if len(failed) > 0 {
return nil, errors.Join(failed...)
}
+13 -77
View File
@@ -125,7 +125,6 @@ func TestAWalksBuildSaysWhetherItReusedOrBuilt(t *testing.T) {
// build as it judges one the walk built. A scratch reference is refused. Needs the store (MESH_TEST_POSTGRES), and
// is named in testdata/store-tests.
func TestAReusedCheckBuildIsRegisteredByItsDigestAndGatedAsABuiltOne(t *testing.T) {
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
g := aGateMesh(t)
ctx := t.Context()
inv := g.open.inventory
@@ -191,90 +190,27 @@ func TestAReusedCheckBuildIsRegisteredByItsDigestAndGatedAsABuiltOne(t *testing.
}
}
// A walk's leave names the builds its own pull requests' verdicts made at the head each merge merged — never an
// earlier head's — read once per walk, and none when they cannot be read: the builder takes nothing a verdict does
// not name (novox/hq ADR 0350 §2, issue 703).
// A walk's leave names the builds its own pull requests' verdicts made, and none when they cannot be read: the
// builder takes nothing a verdict does not name (novox/hq ADR 0350 §2).
func TestAWalksLeaveNamesTheBuildsItsPullRequestsVerdictsMade(t *testing.T) {
was := checkedBuildsOf
forgetWalkVerdicts()
t.Cleanup(func() { checkedBuildsOf = was; forgetWalkVerdicts() })
var asked [][]link.PullRef
pull := link.PullRef{Owner: "novox", Repo: "shop", Number: 7}
atHead := link.CheckedBuild{Pull: pull, Check: "build-2", Head: "bbbbbbbbbbbb", Built: link.CheckBuilt{Module: "app", Key: "r1:2"}}
earlier := link.CheckedBuild{Pull: pull, Check: "build-1", Head: "aaaaaaaaaaaa", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}
t.Cleanup(func() { checkedBuildsOf = was })
var asked []link.PullRef
named := []link.CheckedBuild{{Pull: link.PullRef{Owner: "novox", Repo: "shop", Number: 7}, Check: "build-1",
Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}}
checkedBuildsOf = func(_ context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
asked = append(asked, pulls)
return []link.CheckedBuild{atHead, earlier}, nil
asked = pulls
return named, nil
}
pulls := []link.PullRef{pull}
heads := map[link.PullRef]string{pull: "bbbbbbbbbbbb0123456789"}
for range 3 {
if got := reuseWithVerdicts(t.Context(), "plan-1", &link.ReuseAsk{Pulls: pulls}, heads); !reflect.DeepEqual(got.Builds,
[]link.CheckedBuild{atHead}) {
t.Fatalf("the leave names %+v, not only the merged head's build", got.Builds)
}
}
if !reflect.DeepEqual(asked, [][]link.PullRef{pulls}) {
t.Fatalf("the verdicts were read %d times for one walk: %v", len(asked), asked)
}
// A merge whose head was not said takes nothing.
if got := reuseWithVerdicts(t.Context(), "plan-1", &link.ReuseAsk{Pulls: pulls}, nil); got.Builds != nil {
t.Fatalf("a merge of no said head names %+v", got.Builds)
pulls := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}}
if got := reuseWithVerdicts(t.Context(), &link.ReuseAsk{Pulls: pulls}); !reflect.DeepEqual(got.Builds, named) ||
!reflect.DeepEqual(asked, pulls) {
t.Fatalf("the leave names %+v, read for %+v", got.Builds, asked)
}
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
return nil, errors.New("no bus")
}
if got := reuseWithVerdicts(t.Context(), "plan-2", &link.ReuseAsk{Pulls: pulls}, heads); got.Builds != nil {
if got := reuseWithVerdicts(t.Context(), &link.ReuseAsk{Pulls: pulls}); got.Builds != nil {
t.Fatalf("verdicts that could not be read named %+v", got.Builds)
}
}
// A check is handed only the builds earlier checks of its own pull request at its own head made: another pull
// request's build, or an earlier head's, is never kept (novox/hq issue 703). None when they cannot be read.
func TestACheckIsHandedOnlyItsOwnHeadsBuilds(t *testing.T) {
was := checkedBuildsOf
t.Cleanup(func() { checkedBuildsOf = was })
pull := link.PullRef{Owner: "novox", Repo: "shop", Number: 7}
mine := link.CheckedBuild{Pull: pull, Check: "build-1", Head: "bbbbbbbbbbbb", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}
earlier := link.CheckedBuild{Pull: pull, Check: "build-0", Head: "aaaaaaaaaaaa", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}
another := link.CheckedBuild{Pull: link.PullRef{Owner: "novox", Repo: "shop", Number: 8}, Check: "build-9",
Head: "bbbbbbbbbbbb", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}
var asked [][]link.PullRef
checkedBuildsOf = func(_ context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
asked = append(asked, pulls)
return []link.CheckedBuild{mine, earlier, another}, nil
}
p := link.PullUpdated{Owner: "novox", Repo: "shop", Number: 7, Commit: "bbbbbbbbbbbb0123456789"}
builds := []link.CheckBuild{{Module: "app"}}
if got := namedForTheCheck(t.Context(), p, builds); !reflect.DeepEqual(got, []link.CheckedBuild{mine}) ||
!reflect.DeepEqual(asked, [][]link.PullRef{{pull}}) {
t.Fatalf("the check is handed %+v, read for %v", got, asked)
}
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
return nil, errors.New("no bus")
}
if got := namedForTheCheck(t.Context(), p, builds); got != nil {
t.Fatalf("verdicts that could not be read named %+v", got)
}
}
func forgetWalkVerdicts() {
walkVerdicts.Lock()
defer walkVerdicts.Unlock()
walkVerdicts.read = map[string]verdictsRead{}
}
// A walk's merged heads are the forge's word for each merge; a merge announced without its head, or a pull request
// merged at two heads in one walk, has none, and its walk takes no check's build (novox/hq issue 703).
func TestAWalksMergedHeadsAreTheForgesWord(t *testing.T) {
merge := func(number int, head string) inventory.PlanMerge {
return inventory.PlanMerge{Repository: "novox/shop", Number: number, Head: head}
}
p := &inventory.Plan{Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
merge(7, "aaaaaaaaaaaa1"), merge(8, ""), merge(9, "bbbbbbbbbbbb"), merge(9, "cccccccccccc")}}}
heads := mergedHeadsOf(p)
shop := func(n int) link.PullRef { return link.PullRef{Owner: "novox", Repo: "shop", Number: n} }
if heads[shop(7)] != "aaaaaaaaaaaa1" || heads[shop(8)] != "" || heads[shop(9)] != "" {
t.Fatalf("merged heads %v", heads)
}
}
+25 -193
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"path"
"regexp"
@@ -189,8 +188,6 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
if err := ask.Ask(ctx, request); err != nil {
return err
}
// A new head ends the check of the old one (novox/hq issue 726).
endingSupersededChecks(ctx, seat, p, request.ID, f.oneShot)
what := "its own merge-check.sh alone: " + noModuleTouched
if scope.gated() {
what = fmt.Sprintf("the gate over %s (a merge would build %d module(s) in %d tier(s))",
@@ -251,16 +248,31 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
if err != nil {
return link.BuildRequest{}, err
}
// **A pull request into a release branch is judged by that branch** (novox/hq ADR 0358): asked of the forge once.
release, err := releaseBranchOf(ctx, p, scope, entries)
if err != nil {
return link.BuildRequest{}, err
// Beside it, at what the mesh runs: each core repository by the module the mesh builds from it.
beside := map[string]link.CheckedOut{}
for _, e := range entries {
dir, core := coreModules[e.Manifest.Module]
if !core || e.Provided || e.Source.Repository == "" {
continue
}
url, err := clone(e.Source)
if err != nil {
return link.BuildRequest{}, err
}
refs := besideRefs(dir, current[e.Manifest.Module].Commit)
beside[dir] = link.CheckedOut{Repository: url, Ref: refs[dir]}
if dir == "mesh-controller" {
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: refs["mesh-controller-main"]}
if e.Source.Seat != "" {
for _, sibling := range []string{"mesh-lab", "mesh-sdk"} {
if url, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository,
sibling)}); err == nil {
beside[sibling] = link.CheckedOut{Repository: url, Ref: refs[sibling]}
}
}
}
}
}
beside, err := besideOf(entries, func(module string) string { return current[module].Commit }, clone, release)
if err != nil {
return link.BuildRequest{}, err
}
builds := checkBuildsOf(p, scope, entries, clone)
return link.BuildRequest{
ID: link.NewBuildID(time.Now()),
Repository: repository,
@@ -270,188 +282,10 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
Source: sourceOnSeat(source),
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: namedForTheCheck(ctx, p, builds),
Release: release, Running: runningBy(ctx)},
Manifests: scope.Manifests, Judge: scope.Judge, Builds: checkBuildsOf(p, scope, entries, clone)},
}, nil
}
// besideOf is what a check reads beside its own repository, by the directory it is found under: each core repository
// by the module the mesh builds from it, at besideRefs — or, for a pull request into a release branch (novox/hq ADR
// 0358 §3), at its branch of that name where it has one and at its trunk otherwise, the controller's main kept for
// its judge, and mesh-lab at its trunk whatever the base: its replays run with the container runtime's socket. running
// is the commit the mesh runs of a module.
func besideOf(entries []inventory.Entry, running func(module string) string,
clone func(inventory.Source) (string, error), release string) (map[string]link.CheckedOut, error) {
beside := map[string]link.CheckedOut{}
for _, e := range entries {
dir, core := coreModules[e.Manifest.Module]
if !core || e.Provided || e.Source.Repository == "" {
continue
}
url, err := clone(e.Source)
if err != nil {
return nil, err
}
refs := besideRefs(dir, running(e.Manifest.Module))
at := func(dir, trunk string) link.CheckedOut {
if release == "" {
return link.CheckedOut{Repository: url, Ref: refs[dir]}
}
if trunk == "" {
trunk = "main"
}
return link.CheckedOut{Repository: url, Ref: release, Else: trunk}
}
beside[dir] = at(dir, followedBranch(e.Source.Ref))
if dir == "mesh-controller" {
// The controller's main stays its main: it judges only a check the running controller predates.
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: refs["mesh-controller-main"]}
if release != "" {
// mesh-controller has the release branch: that is what makes it one (ADR 0358 §1).
beside[dir] = link.CheckedOut{Repository: url, Ref: release}
}
if e.Source.Seat != "" {
for _, sibling := range []string{"mesh-lab", "mesh-sdk"} {
if url, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository,
sibling)}); err == nil {
b := link.CheckedOut{Repository: url, Ref: refs[sibling]}
// **mesh-lab is read at its trunk, always** (ADR 0358): its replays run with the container
// runtime's socket, so they are only ever reviewed code — never a branch anyone may create.
if release != "" && sibling != "mesh-lab" {
b = link.CheckedOut{Repository: url, Ref: release, Else: "main"}
}
beside[sibling] = b
}
}
}
}
}
return beside, nil
}
// releaseBranchOf is the release branch a pull request merges into (novox/hq ADR 0358 §1): its base, when that is not
// its repository's trunk and mesh-controller has a branch of that name; empty otherwise, and the pull request is
// checked as before it. **A base that is a trunk asks nothing**: the branch a module built from the repository follows
// (main when it records none), or mesh-controller's own trunk. Any other base is asked of the forge once, and an
// answer it cannot give is an error, never read as "no release branch".
func releaseBranchOf(ctx context.Context, p link.PullUpdated, scope checkScope, entries []inventory.Entry) (string, error) {
base := p.Base
if base == "" {
return "", nil
}
if scope.From != nil && base == trunkOf(scope.From.Source) {
return "", nil
}
var controller *inventory.Entry
for i, e := range entries {
if e.Manifest.Module == "mesh-controller" && !e.Provided && e.Source.Repository != "" {
controller = &entries[i]
break
}
}
if controller == nil || base == trunkOf(controller.Source) {
return "", nil
}
if !tryRef.MatchString(base) || strings.Contains(base, "..") {
return "", nil // no branch the forge could hold: checked as before
}
owner, repo := sourceOwner(controller.Source.Repository), sourceName(controller.Source.Repository)
if owner == "" || repo == "" {
return "", nil
}
has, err := forgeHasBranch(ctx, owner, repo, base)
if err != nil {
return "", fmt.Errorf("whether %s/%s#%d merges into a release branch cannot be told — the forge did not say "+
"whether %s/%s has a branch %s (novox/hq ADR 0358): %w", p.Owner, p.Repo, p.Number, owner, repo, base, err)
}
if !has {
return "", nil
}
return base, nil
}
// trunkOf is the branch a module's source follows: main when it records none, or records a commit.
func trunkOf(s inventory.Source) string {
if t := strings.TrimSpace(followedBranch(s.Ref)); t != "" {
return t
}
return "main"
}
// sourceName is the name of a recorded repository, a path on the git seat or a URL: novox/mesh-host → mesh-host.
func sourceName(repository string) string {
parts := strings.Split(strings.Trim(strings.TrimSuffix(repository, ".git"), "/"), "/")
if len(parts) < 2 {
return ""
}
return parts[len(parts)-1]
}
// forgeHasBranch asks the forge, through its module's tools on the bus, whether a repository has a branch. A
// variable so a test needs no forge.
var forgeHasBranch = func(ctx context.Context, owner, repo, branch string) (bool, error) {
js, err := aBus()
if err != nil {
return false, err
}
defer js.Close()
raw, _ := json.Marshal(map[string]any{"path": "/repos/" + owner + "/" + repo + "/branches/" + branch})
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second)
if err != nil {
return false, err
}
return branchAnswered(answer, branch)
}
// branchAnswered reads the forge's answer about a branch: a 404 is "no such branch"; an answer naming the branch is
// "it has it"; anything else — another error, an unreadable answer, one naming no branch or another — is an error,
// never read as "no release branch".
func branchAnswered(answer link.Answer, branch string) (bool, error) {
if answer.Error != "" {
if strings.Contains(answer.Error, " 404 ") {
return false, nil
}
return false, errors.New(answer.Error)
}
var found struct {
Result struct {
Name string `json:"name"`
} `json:"result"`
}
if err := json.Unmarshal(answer.Result, &found); err != nil {
return false, fmt.Errorf("the forge's answer about branch %s cannot be read: %w", branch, err)
}
if found.Result.Name != branch {
return false, fmt.Errorf("the forge answered about branch %s naming %q", branch, found.Result.Name)
}
return true, nil
}
// namedForTheCheck are the builds earlier checks of the same pull request at the same head named, read once for the
// check (novox/hq issue 703): asked again because only the trunk moved, the check keeps what they built of a key
// still the same. Another pull request's build, or one of an earlier head, is never kept. None when they cannot be
// read, and the check builds, as before.
func namedForTheCheck(ctx context.Context, p link.PullUpdated, builds []link.CheckBuild) []link.CheckedBuild {
if len(builds) == 0 || p.Commit == "" {
return nil
}
pull := link.PullRef{Owner: p.Owner, Repo: p.Repo, Number: p.Number}
read, err := checkedBuildsOf(ctx, []link.PullRef{pull})
if err != nil {
fmt.Printf(" the earlier checks' builds of %s/%s#%d could not be read, so the check builds: %v\n",
p.Owner, p.Repo, p.Number, err)
// empty-on-error: said above; a check handed no named build builds every module, as before issue 703
return nil
}
var named []link.CheckedBuild
for _, b := range read {
if b.Pull == pull && link.SameHead(b.Head, p.Commit) {
named = append(named, b)
}
}
return named
}
// checkBuildsOf are the modules a merge of the change would build, in the walk's order, as the check builds them
// for the walk to take (novox/hq ADR 0350 §1): a module built from the repository checked, at the branch it merges
// into, from the merge the check makes; any other — a module standing on one — from its own repository at the
@@ -598,8 +432,6 @@ func checkedOf(result link.BuildResult) link.Checked {
// repositories read beside it.
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
c.Built = result.Check.Built
c.Release, c.JudgedBy, c.BesideAt = result.Check.Release, result.Check.JudgedBy, result.Check.BesideAt
c.Validator = result.Check.Validator
if c.Gate == nil {
// A build seat from before the layers: its verdict is the gate's.
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
+1 -1
View File
@@ -952,7 +952,7 @@ func deliveryCommand(ctx context.Context, args []string) error {
if *group == "" && len(members) == 1 {
// One head, checked again as the forge's announcement would have it (a recheck): the controller's
// own path for a pull request, run because the delivery's owner asked.
if err := sayingOnTheBus(ctx, inv, func() error { return (following{open: open, oneShot: true}).PullUpdated(ctx, members[0].pull()) }); err != nil {
if err := sayingOnTheBus(ctx, inv, func() error { return (following{open}).PullUpdated(ctx, members[0].pull()) }); err != nil {
return err
}
return answer(map[string]any{"rechecked": members[0].ID})
-3
View File
@@ -22,7 +22,6 @@ func foundDirectory(module string, since time.Time) inventory.ResourceHealth {
}
func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
now := time.Now()
sent := now.Add(-time.Minute)
f := gateFacts{now: now, health: map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now,
@@ -49,7 +48,6 @@ func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
// The whole walk: a module whose directory was used as found long before still gets its fix to every machine,
// its pass kept and the wait said; a directory this very send found holds it and puts it back.
func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
for _, c := range []struct {
name string
found time.Duration // when the directory was found, against now
@@ -118,7 +116,6 @@ func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
// The condition says the wait in its own kind: the operator's, never urgent, and cleared once handed over.
func TestADirectoryUsedAsFoundIsItsOwnCondition(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
k, _ := withConditionsInMemory(t)
ctx := t.Context()
rs := map[string][]inventory.ResourceHealth{"notes": {foundDirectory("notes", time.Now().Add(-time.Hour))}}
@@ -57,7 +57,6 @@ func healthyContainer(module string) inventory.ResourceHealth {
// tools' witness put its build back at once, and the send waits for the healthy module's own verdict — a pass,
// counted over the gate's spacing — before it says its own. The broken one alone is put back.
func TestAFaultBeforeTheSettleTimeWaitsForTheModuleBesideIt(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
open := aMesh(t)
ctx := t.Context()
@@ -100,7 +99,6 @@ const broker_runtime = "node-tools"
// **A pass is counted only the gate's spacing after the one before** (review (c)): a send judged every tick
// while a module beside it is not yet healthy counts the healthy one once.
func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
withGateBounds(t, time.Hour, time.Hour, time.Hour)
@@ -125,7 +123,6 @@ func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) {
// **A carried build's pass carries its own wait, never another's** (review (e)).
func TestACarriedPassCarriesOnlyItsOwnWait(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
b := aBacklog(t)
ctx := t.Context()
inv := b.open.inventory
@@ -150,7 +147,6 @@ func TestACarriedPassCarriesOnlyItsOwnWait(t *testing.T) {
// carried its own module, healthy, and a build waiting on that machine that never became healthy. At the
// bound the waiting one is put back and marked; the plan's own module keeps its pass, so no walk waits on it.
func TestThePlansOwnModuleKeepsItsPassWhenItsSendFails(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
g := aGateMesh(t)
ctx := t.Context()
inv := g.open.inventory
@@ -231,7 +227,6 @@ func TestThePlansOwnModuleKeepsItsPassWhenItsSendFails(t *testing.T) {
// **A provider waiting for a login says who waits on it** (review (g)): its consumer, failing a check that
// needs it, is held under it, and the provider's relogin-needed condition lists it and is urgent.
func TestAProviderWaitingForALoginSaysWhoWaitsOnIt(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
@@ -294,7 +289,6 @@ func TestAProviderWaitingForALoginSaysWhoWaitsOnIt(t *testing.T) {
// tools are marked and put back in the very judging that found them broken — their registered build is the
// one before, and the laptop is sent it — while `app` goes on being judged, recovers, and keeps its own pass.
func TestABrokenModuleIsPutBackAtOnceAndTheOneBesideItGetsItsOwnVerdict(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
open := aMesh(t)
ctx := t.Context()
@@ -437,7 +431,6 @@ func TestABrokenModuleIsPutBackAtOnceAndTheOneBesideItGetsItsOwnVerdict(t *testi
// **A move from a build not known excuses no wait** (issue 318 review): a plan's own module whose previous
// build was not recorded, or whose previous manifest is not kept, cannot be shown to have added the group.
func TestAMoveFromAnUnknownBuildExcusesNoWait(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
-32
View File
@@ -36,10 +36,6 @@ func aGateMesh(t *testing.T) *gateMesh {
ctx := t.Context()
inv := open.inventory
g := &gateMesh{open: open, health: map[string]health{}}
// The gate's own tests judge a first machine, as walks a controller before novox/hq ADR 0360 started still are.
wasGated := gateFirstMachine
gateFirstMachine = true
t.Cleanup(func() { gateFirstMachine = wasGated })
g.keeper, _ = withConditionsInMemory(t)
g.told = &conditions.Told{}
was := doctorFrom
@@ -275,34 +271,6 @@ func TestABuildThatPassesItsGateRollsEverywhereUnattended(t *testing.T) {
}
}
// A build rolls everywhere with no hand and no gate (novox/hq ADR 0360 §1): both machines in one send, no health
// judged, and the walk done once both reported on the send. Before, anchor was judged first, then laptop sent.
func TestABuildRollsToEveryMachineAtOnceWithNoGate(t *testing.T) {
g := aGateMesh(t)
gateFirstMachine = false
ctx := t.Context()
judged := false
wasGather := gatherGateFacts
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
judged = true
return wasGather(ctx, open, component)
}
t.Cleanup(func() { gatherGateFacts = wasGather })
for i := 0; i < 6; i++ {
advancePlans(ctx, g.open)
}
p := g.plan(t)
if !reflect.DeepEqual(g.sent, [][]string{{"anchor", "laptop"}}) {
t.Fatalf("sent %v, not both machines in one send", g.sent)
}
if s := p.Modules["app"]; p.State != inventory.PlanDone || s.Gate != nil || s.FirstAt != nil || judged {
t.Fatalf("the plan is %s (%s), app %+v, judged %v", p.State, p.Note, s, judged)
}
if obs, err := probeGates(ctx, doctorFrom); err != nil || len(obs) != 0 {
t.Fatalf("a rolled build left a condition: %+v %v", obs, err)
}
}
// A leaf module passes its gate on one reading, as soon as its first machine is healthy, without waiting out the
// core's settle time (novox/hq ADR 0282 decision 4 as ADR 0304 decision 3 changed it, issue 524).
func TestALeafModulePassesOnOneHealthyReading(t *testing.T) {
-8
View File
@@ -25,7 +25,6 @@ import (
// another send reaches anchor, unreported; the gate still passes. And a send that moves a judged module
// to another build supersedes the judging: no verdict, nothing put back.
func TestReplay352(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
t.Run("a newer send to the judged machine does not unreport the gate's", testANewerSendDoesNotUnreportTheGatesOwn)
t.Run("a send that moves the module supersedes the judging", testASendThatMovesTheModuleSupersedesTheJudging)
@@ -68,7 +67,6 @@ func testANewerSendDoesNotUnreportTheGatesOwn(t *testing.T) {
// A plan's own first send waits while a release judges the same module on that machine with another build.
func TestAPlansFirstSendWaitsForAReleaseJudgingTheModuleThere(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
b := aBacklog(t)
ctx := t.Context()
@@ -84,7 +82,6 @@ func TestAPlansFirstSendWaitsForAReleaseJudgingTheModuleThere(t *testing.T) {
// A merge plan's judging is superseded the same way: another send moved its module on the first machine.
func TestAPlansJudgingIsSupersededByASendThatMovesItsModule(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
g := aGateMesh(t)
ctx := t.Context()
@@ -143,7 +140,6 @@ func testASendThatMovesTheModuleSupersedesTheJudging(t *testing.T) {
// A report is on the gate's own send: the declaration itself, or one sequenced after it; a gate kept
// without its send reads the report against the send made last, as before. Pure.
func TestAReportIsHeldAgainstTheGatesOwnSend(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
sent := inventory.SentDeclaration{Digest: "d-490", Sequence: 490}
for _, c := range []struct {
r inventory.Reported
@@ -189,7 +185,6 @@ func TestAReportIsHeldAgainstTheGatesOwnSend(t *testing.T) {
// A gate judges only the build the machine was last sent: last sent another build of the module, the
// judging is superseded, whatever the machine reports. Pure.
func TestAGateJudgesOnlyTheBuildTheMachineWasLastSent(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
at := time.Now()
f := gateFacts{now: at, reports: map[string]inventory.Reported{"anchor": {Node: "anchor", Outcome: inventory.OutcomeApplied,
At: &at, Current: true}}, engines: map[string]string{}, served: map[string]served{}, rolledBack: map[string][]lease.Rollback{},
@@ -217,7 +212,6 @@ func TestAGateJudgesOnlyTheBuildTheMachineWasLastSent(t *testing.T) {
// A move of another build of a module to a machine where a release or a plan is judging that module
// waits for that judging; the same build to that machine is already there. Pure.
func TestAWalkWaitsForAJudgingOfTheSameModuleOnThatMachine(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
at := time.Now()
release := inventory.Plan{ID: "release-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{
Gate: &inventory.PlanGate{Machines: []string{"novox"}, Carried: []inventory.CarriedMove{
@@ -255,7 +249,6 @@ func TestAWalkWaitsForAJudgingOfTheSameModuleOnThatMachine(t *testing.T) {
// The controller is never put back to a build that reaches less of the store's schema than the store
// has, or to one that never said what it reaches: the current build is kept, and the condition says so.
func TestTheControllerIsNotPutBackToABuildOlderThanTheStoresSchema(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
@@ -352,7 +345,6 @@ func TestTheControllerIsNotPutBackToABuildOlderThanTheStoresSchema(t *testing.T)
// The lease's holder names the build the declaration told it it is, and the version stamp only without one.
func TestTheHolderNamesTheBuildTheDeclarationToldIt(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
t.Setenv(RunningBuildVar, " ad62528c47c7 ")
if h := holderOf("x"); h.Build != "ad62528c47c7" {
t.Fatalf("the holder's build is %q", h.Build)
+5 -6
View File
@@ -20,8 +20,8 @@ func TestANoMoveTierWaitsForNoNewReport(t *testing.T) {
ctx := t.Context()
inv := g.open.inventory
// app holds the build seat, so a module of the next tier is built by it; since novox/hq ADR 0360 §3 that gates
// nothing.
// app is the build machine, so a module of the next tier is built by it: the tier gates the next on app
// running on its machines.
for _, m := range []catalogue.Manifest{
{Module: "app", Version: "c2", Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}},
{Module: "web", Version: "w1"},
@@ -71,8 +71,7 @@ func TestANoMoveTierWaitsForNoNewReport(t *testing.T) {
t.Fatalf("the walk waits on a no-move tier for a report nothing will cause: tier %d, %s %q", p.Tier, p.State, p.Note)
}
// A machine that has not answered its last send is not waited on either: a built-by edge gates no tier
// (novox/hq ADR 0360 §3). Before, the walk waited for app on laptop.
// A machine that has not answered its last send is still waited on: no move is not "never sent".
p.Tier, p.State, p.Note = 0, inventory.PlanRolling, ""
if err := inv.RecordSent(ctx, nodeID(t, g.open, "laptop"), "d-laptop-later", map[string]string{"app": "c1"}); err != nil {
t.Fatal(err)
@@ -82,7 +81,7 @@ func TestANoMoveTierWaitsForNoNewReport(t *testing.T) {
}
advancePlans(ctx, g.open)
p = g.plan(t)
if p.Tier < 1 || strings.Contains(p.Note, "waiting for app") {
t.Fatalf("the walk waits on a built-by edge: tier %d, %q", p.Tier, p.Note)
if p.Tier != 0 || !strings.Contains(p.Note, "waiting for app on laptop") {
t.Fatalf("a machine that has not answered its last send was not waited on: tier %d, %q", p.Tier, p.Note)
}
}
-188
View File
@@ -1,188 +0,0 @@
package main
import (
"context"
"slices"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
)
// novox/hq ADR 0360 §4, §5: a walk ends on its machines' reports — every machine sent has reported on its send,
// or is silent past ApplySilentAfter — and names a failed or refused apply; it never waits for a health reading.
func TestAWalkEndsOnItsMachinesReports(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
sent := now.Add(-time.Minute)
long := now.Add(-inventory.ApplySilentAfter - time.Minute)
at := now.Add(-10 * time.Second)
p := inventory.Plan{Modules: map[string]*inventory.PlanModule{
"app": {Rest: map[string]inventory.SentDeclaration{
"ace": {Digest: "d-ace", At: sent},
"novox": {Digest: "d-novox", At: sent},
"shanks": {Digest: "d-shanks", At: sent},
"laptop": {Digest: "d-laptop", At: long},
}},
"proxy": {Rest: map[string]inventory.SentDeclaration{"ace": {Digest: "d-ace", At: sent}}},
}}
reports := []inventory.Reported{
{Node: "ace", Outcome: inventory.OutcomeApplied, At: &at, Declared: "d-ace"},
{Node: "novox", Outcome: inventory.OutcomeFailed, At: &at, Declared: "d-novox"},
{Node: "shanks", Outcome: inventory.OutcomeApplied, At: &at, Declared: "d-before"},
}
waiting, failed, silent := walkReports(p, reports, now)
if !slices.Equal(waiting, []string{"shanks"}) || !slices.Equal(failed, []string{"novox"}) ||
!slices.Equal(silent, []string{"laptop"}) {
t.Fatalf("waiting %v, failed %v, silent %v", waiting, failed, silent)
}
reports[2].Declared = "d-shanks"
if waiting, _, _ := walkReports(p, reports, now); len(waiting) != 0 {
t.Fatalf("the walk still waits once every machine reported: %v", waiting)
}
}
// withTheGate turns the first-machine gate on for a test of its own path: kept for the walks a controller before
// novox/hq ADR 0360 started.
func withTheGate(t *testing.T) {
t.Helper()
was := gateFirstMachine
gateFirstMachine = true
t.Cleanup(func() { gateFirstMachine = was })
}
// Every machine heard from is sent at once and passed on its applied report, with no judging (novox/hq ADR 0360 §7);
// before, the first machine was judged before the rest were sent (ADR 0353 §1).
func TestAReleaseSendsEveryMachineAtOnce(t *testing.T) {
judgeLeavesAsTheCore(t)
b := aBacklogOnThree(t)
ctx := t.Context()
said := events(t)
for i := 0; i < 12; i++ {
advancePlans(ctx, b.open)
}
p := b.release(t)
if p.State != inventory.PlanDone || len(p.Release.Done) != 3 {
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
}
for _, n := range []string{"desk", "laptop", "anchor"} {
if !slices.Contains(*said, "send "+n) {
t.Fatalf("%s was not sent: %v", n, *said)
}
}
if slices.Contains(*said, "judge") {
t.Fatalf("a machine was judged by its health: %v", *said)
}
if v, found, err := b.open.inventory.GateOf(ctx, "build-late-c2"); err != nil || !found || v.Verdict != inventory.GatePassed {
t.Fatalf("late's pass on laptop was not kept: %+v %v %v", v, found, err)
}
}
// A machine that reports its apply failed is named and fails the release, with nothing put back, and every other
// machine goes on to its own report (novox/hq ADR 0360 §5, §7); before, it was put back (ADR 0353 §3).
func TestAMachineThatFailsItsApplyIsNamedAndNothingIsPutBack(t *testing.T) {
judgeLeavesAsTheCore(t)
b := aBacklogOnThree(t)
ctx := t.Context()
inv := b.open.inventory
b.broken["desk"] = true // desk fails what it is sent
advancePlans(ctx, b.open)
for i := 0; i < 12 && b.release(t).Open(); i++ {
advancePlans(ctx, b.open)
}
p := b.release(t)
if p.State != inventory.PlanFailed || !slices.Equal(p.Release.Failed, []string{"desk"}) {
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
}
if !slices.Contains(p.Release.Done, "laptop") || !slices.Contains(p.Release.Done, "anchor") {
t.Fatalf("the others have no verdict of their own: %+v", p.Release)
}
if v, found, err := inv.GateOf(ctx, "build-late-c2"); err != nil || !found || v.Verdict != inventory.GatePassed {
t.Fatalf("late's pass on laptop was not kept: %+v %v %v", v, found, err)
}
if sent, _, _ := inv.SentBuilds(ctx, "desk"); sent["app"] != "c2" {
t.Fatalf("desk was put back: %v", sent)
}
}
// A release's send kept before it was made, and made, is judged by the controller that takes over without being
// made again (ADR 0353 §8), with every machine sent at once (ADR 0360 §7).
func TestAReleaseSendKeptAndMadeIsNotMadeAgainAtOnce(t *testing.T) {
judgeLeavesAsTheCore(t)
b := aBacklog(t)
ctx := t.Context()
inv := b.open.inventory
p, err := releaseBacklog(ctx, b.open, "")
if err != nil || p == nil {
t.Fatalf("no release opened: %v", err)
}
if _, err := advanceRelease(ctx, b.open, p); err != nil || p.Release.Gates["anchor"] == nil || !p.Release.Gates["anchor"].Pending {
t.Fatalf("the send was not kept before it was made: %+v %v", p.Release, err)
}
if err := inv.SavePlan(ctx, p); err != nil {
t.Fatal(err)
}
// The send is made, and the controller is replaced before it keeps it.
if _, err := sendRollout(ctx, b.open, []string{"anchor"}); err != nil {
t.Fatal(err)
}
advancePlans(ctx, b.open)
if n := len(slices.DeleteFunc(slices.Clone(b.sent), func(s []string) bool { return !slices.Equal(s, []string{"anchor"}) })); n != 1 {
t.Fatalf("anchor was sent again: %v", b.sent)
}
g := b.release(t).Release.Gates["anchor"]
if g == nil || g.Pending || g.Since == nil || len(g.Carried) == 0 {
t.Fatalf("anchor is not judged on the send made: %+v", g)
}
}
// A machine of the rest that says nothing on its send is left silent after fifteen minutes: not failed, not put
// back; a rest that went silent whole ends failed, said as a condition (ADR 0353 §4); its step also sent anchor, so the release is done, not failed (ADR 0360 §7).
func TestAMachineSilentPastItsBoundIsLeftAndSaidAtOnce(t *testing.T) {
judgeLeavesAsTheCore(t)
b := aBacklog(t)
ctx := t.Context()
inv := b.open.inventory
reporting := sendRollout
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
if slices.Equal(names, []string{"laptop"}) { // laptop sleeps: sent, and says nothing
b.sent = append(b.sent, names)
current, _ := open.inventory.CurrentBuilds(ctx)
return names, open.inventory.RecordSent(ctx, nodeID(t, open, "laptop"), "d-laptop-asleep",
map[string]string{"app": current["app"].Commit, "late": current["late"].Commit, "same": current["same"].Commit})
}
return reporting(ctx, open, names)
}
t.Cleanup(func() { sendRollout = reporting })
for i := 0; i < 8; i++ {
advancePlans(ctx, b.open)
}
p := b.release(t)
g := p.Release.Gates["laptop"]
if !p.Open() || g == nil || g.Verdict != "" || g.Silent {
t.Fatalf("laptop is judged before fifteen minutes: %s %s %+v", p.State, p.Note, g)
}
since := time.Now().Add(-inventory.ApplySilentAfter - time.Minute)
g.Since = &since
if err := inv.SavePlan(ctx, &p); err != nil {
t.Fatal(err)
}
advancePlans(ctx, b.open)
p = b.release(t)
// anchor applied in the same step, so the release is done, laptop named silent (novox/hq ADR 0360 §7).
if p.State != inventory.PlanDone || !slices.Equal(p.Release.Silent, []string{"laptop"}) || len(p.Release.Failed) > 0 {
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
}
if failed, _ := inv.GateFailed(ctx, "build-late-c2"); failed {
t.Fatal("a silent machine's build was marked failed")
}
if sent, _, _ := inv.SentBuilds(ctx, "laptop"); sent["late"] != "c2" {
t.Fatalf("laptop was put back: %v", sent)
}
obs, err := silentObservation(ctx, inv)
if err != nil {
t.Fatal(err)
}
if len(obs) != 1 || obs[0].Kind != "release-silent" || !slices.Equal(obs[0].Also, []string{"laptop"}) {
t.Fatalf("the silence is not said: %+v", obs)
}
}
+3 -96
View File
@@ -7,10 +7,7 @@ import (
"fmt"
"os/exec"
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
@@ -126,82 +123,8 @@ func answerMeshCLI(inv *inventory.Inventory) link.CLIHandler {
// cliJournal says one line in the controller's journal (its standard output); a variable so a test can read it.
var cliJournal = func(line string) { fmt.Println(line) }
// onlyReads says a line only reads, judged by the generic `command` verb's reading forms (novox/hq ADR 0266) — the
// same allow list that decides what an ordinary call may run, never a second list kept here — judged on the line's
// every word, so `plans go`, `conditions silence` and `settings set` are not reads while `plans`, `conditions` and
// `settings show` are. A line those forms do not name is not judged a read, and is said (novox/hq issue 762).
func onlyReads(asked link.CLIAsked, v cliVerdict) bool {
if v.refused != "" || len(asked.Line) == 0 || len(asked.Stdin) > 0 || cliServers[asked.Line[0]] {
return false
}
return commandReads(asked.Line) == nil && terminalOnly(asked.Line) == nil
}
// readCalls counts the read calls said in no line, per caller, until the next hourly line says them.
type readCalls struct {
mu sync.Mutex
counts map[string]int
}
// cliReads are this controller's read calls not yet said.
var cliReads = &readCalls{}
func (r *readCalls) add(caller string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.counts == nil {
r.counts = map[string]int{}
}
r.counts[caller]++
}
// said is the line that says the read calls counted since the last, and starts counting again; empty when there
// were none, so an idle hour says nothing.
func (r *readCalls) said(over time.Duration) string {
r.mu.Lock()
counts := r.counts
r.counts = nil
r.mu.Unlock()
if len(counts) == 0 {
return ""
}
callers := make([]string, 0, len(counts))
for c := range counts {
callers = append(callers, c)
}
sort.Strings(callers)
parts := make([]string, len(callers))
for i, c := range callers {
parts[i] = fmt.Sprintf("%s %d", c, counts[c])
}
return fmt.Sprintf("mesh-cli read calls in the last %s, said in no line of their own: %s", over,
strings.Join(parts, ", "))
}
// readCallsEvery is how often the count of read calls is said.
const readCallsEvery = time.Hour
// sayReadCalls says, every readCallsEvery until ctx ends, how many read calls each caller made — when any did — so
// a runaway poller stays visible while its calls write no line each (novox/hq issue 762).
func sayReadCalls(ctx context.Context) {
tick := time.NewTicker(readCallsEvery)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
if line := cliReads.said(readCallsEvery); line != "" {
cliJournal(line)
}
}
}
}
// runForMeshCLI runs a judged line and answers what it said. Every line that changes something, is refused or fails
// is said in the journal: its call, who asked on which node, its command word only, and how it runs (review of ADR
// 0272). A line that only reads and succeeds is counted instead, and the count said hourly (novox/hq issue 762): a
// page polling `plans` every few seconds would otherwise write thousands of lines a day.
// runForMeshCLI runs a judged line and answers what it said. Every line is said in the journal first: its call,
// who asked on which node, its command word only, and how it runs (review of ADR 0272).
func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliVerdict) link.CLIAnswer {
how := "as an ordinary call"
switch {
@@ -214,23 +137,7 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV
if call == "" {
call = "(no call)"
}
line := fmt.Sprintf("mesh-cli %s: %s on %s asked %q, %s", call, asked.Account, node, asked.Line[0], how)
if !onlyReads(asked, v) {
cliJournal(line)
return runJudgedLine(ctx, node, asked, v)
}
answer := runJudgedLine(ctx, node, asked, v)
if answer.Exit != 0 || answer.Refused != "" {
// A read that was refused or failed is said, as a change is.
cliJournal(fmt.Sprintf("%s, and it failed (exit %d)", line, answer.Exit))
} else {
cliReads.add(fmt.Sprintf("%s on %s", asked.Account, node))
}
return answer
}
// runJudgedLine runs a line runForMeshCLI has said or counted.
func runJudgedLine(ctx context.Context, node string, asked link.CLIAsked, v cliVerdict) link.CLIAnswer {
cliJournal(fmt.Sprintf("mesh-cli %s: %s on %s asked %q, %s", call, asked.Account, node, asked.Line[0], how))
if v.refused != "" {
return link.CLIRefusal(v.refused)
}
-123
View File
@@ -1,123 +0,0 @@
package main
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/link"
)
// echoFails makes the echoing test binary end with a failure, as a read that failed would.
const echoFails = "MESH_TEST_ECHO_FAILS"
// journalled runs lines through runForMeshCLI and answers what the journal said, with a fresh count of read calls.
func journalled(t *testing.T) *[]string {
t.Helper()
t.Setenv(echoEnvironment, "1")
said := &[]string{}
was, wasReads := cliJournal, cliReads
cliJournal = func(line string) { *said = append(*said, line) }
cliReads = &readCalls{}
t.Cleanup(func() { cliJournal, cliReads = was, wasReads })
return said
}
// **A call that only reads writes no journal line; one that changes something does** (novox/hq issue 762).
func TestAReadCallWritesNoJournalLine(t *testing.T) {
said := journalled(t)
ctx := link.WithCallID(context.Background(), "call-1")
ordinary := cliVerdict{why: "not the terminal"}
for _, line := range [][]string{{"plans"}, {"status"}, {"conditions"}, {"node", "list"}, {"builds"}, {"queue"},
{"settings", "show", "zsh"}, {"plans", "--json"}} {
if a := runForMeshCLI(ctx, "g14", cliAsked("jochen", 1000, line...), ordinary); a.Exit != 0 || a.Refused != "" {
t.Fatalf("%q did not run: %+v", line, a)
}
}
runForMeshCLI(ctx, "control", cliAsked("jochen", 1000, "plans"), cliVerdict{terminal: true, why: "the terminal"})
if len(*said) != 0 {
t.Fatalf("read calls wrote journal lines: %q", *said)
}
runForMeshCLI(ctx, "control", cliAsked("jochen", 1000, "assign", "g14", "zsh"), cliVerdict{terminal: true, why: "the terminal"})
if len(*said) != 1 || !strings.Contains((*said)[0], `asked "assign"`) {
t.Fatalf("a call that changes something was not said: %q", *said)
}
}
// **A refused or failed read keeps its line** (novox/hq issue 762).
func TestAFailedOrRefusedReadIsSaid(t *testing.T) {
said := journalled(t)
runForMeshCLI(context.Background(), "g14", cliAsked("root", 0, "plans"), cliVerdict{refused: "root is not answered"})
t.Setenv(echoFails, "1")
a := runForMeshCLI(context.Background(), "g14", cliAsked("jochen", 1000, "plans"), cliVerdict{why: "not the terminal"})
if a.Exit == 0 {
t.Fatalf("the read did not fail: %+v", a)
}
if len(*said) != 2 || !strings.Contains((*said)[0], "refused") || !strings.Contains((*said)[1], "failed (exit 3)") {
t.Fatalf("the journal said %q", *said)
}
if line := cliReads.said(readCallsEvery); line != "" {
t.Fatalf("a failed read was counted as a read: %q", line)
}
}
// **A verb is classed by what the call does, not its name** (novox/hq issue 762): the same command word reads with
// some arguments and writes with others, and a line no reading form names is said.
func TestAVerbIsClassedByItsArguments(t *testing.T) {
ordinary := cliVerdict{why: "not the terminal"}
for _, c := range []struct {
line []string
reads bool
}{
{[]string{"settings", "show", "zsh"}, true},
{[]string{"settings", "preferences"}, true},
{[]string{"settings", "set", "zsh", `{"a":1}`}, false},
{[]string{"settings", "clear", "zsh"}, false},
{[]string{"plans"}, true},
{[]string{"plans", "p-1"}, true},
{[]string{"plans", "go", "p-1"}, false},
{[]string{"plans", "--json", "stop", "p-1"}, false},
{[]string{"plans", "close", "p-1"}, false},
{[]string{"plans", "retry", "p-1"}, false},
{[]string{"conditions"}, true},
{[]string{"conditions", "show", "x"}, true},
{[]string{"conditions", "silence", "x"}, false},
{[]string{"doctor", "run"}, false},
{[]string{"node", "account", "g14", "x"}, false},
{[]string{"assign", "g14", "zsh"}, false},
{[]string{"a-command-added-later"}, false},
{[]string{"serve"}, false},
} {
if got := onlyReads(cliAsked("jochen", 1000, c.line...), ordinary); got != c.reads {
t.Errorf("%q judged a read: %v, want %v", c.line, got, c.reads)
}
}
if onlyReads(cliAsked("jochen", 1000, "plans"), cliVerdict{refused: "no"}) {
t.Error("a refused line judged a read")
}
stdin := cliAsked("jochen", 1000, "status")
stdin.Stdin = []byte("x")
if onlyReads(stdin, cliVerdict{terminal: true}) {
t.Error("a line carrying standard input judged a read")
}
}
// **Once an hour the read calls are counted per caller, only if there were any** (novox/hq issue 762).
func TestTheReadCallsAreCountedPerCaller(t *testing.T) {
said := journalled(t)
ordinary := cliVerdict{why: "not the terminal"}
for range 3 {
runForMeshCLI(context.Background(), "g14", cliAsked("jochen", 1000, "plans"), ordinary)
}
runForMeshCLI(context.Background(), "ace", cliAsked("jochen", 1000, "status"), ordinary)
if len(*said) != 0 {
t.Fatalf("read calls wrote lines: %q", *said)
}
line := cliReads.said(readCallsEvery)
if !strings.Contains(line, "jochen on ace 1, jochen on g14 3") || !strings.Contains(line, "1h0m0s") {
t.Fatalf("the hourly line said %q", line)
}
if again := cliReads.said(readCallsEvery); again != "" {
t.Fatalf("an hour with no read calls said %q", again)
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ func owedToTheRecord(ctx context.Context, announced merges, m link.SourceMoved,
// catchingUpOnMerges reads back the forge's announcements on a timer, until the context ends.
func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
f := following{open: open}
f := following{open}
catalogued := func(ctx context.Context) ([]inventory.Entry, map[string][]inventory.ReadRepository, error) {
entries, err := open.inventory.Catalogued(ctx)
if err != nil {
-15
View File
@@ -240,7 +240,6 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
continue
}
o := moduleUnhealthyObservation(m, node, unhealthy[m])
declaredSeverity(&o, hold, m)
if hold != nil {
if by, held := hold.heldWith(node, m, unhealthy[m]); held {
// Held under the provider's condition: nothing of its own, and the provider's says it waits. The
@@ -348,7 +347,6 @@ func sayWaiters(ctx context.Context, k *conditions.Keeper, hold *holding, p cata
} else {
_, rest := besideAWait(p.Module, rs)
o = moduleUnhealthyObservation(p.Module, p.Node, rest)
declaredSeverity(&o, hold, p.Module)
if said, waits := personWait(p.Module, p.Node, rest); waits {
o = reloginObservation(p.Module, p.Node, said, operatorOn(ctx, hold.inv, p.Node), rest)
}
@@ -483,19 +481,6 @@ func sayWaitingOn(o *conditions.Observation, waiters []string) {
o.Explanation += fmt.Sprintf(" %d module(s) that depend on it wait for it.", len(waiters))
}
// declaredSeverity raises a module's unhealthy condition urgent when its manifest declares `unhealthy` urgent
// (novox/hq ADR 0355 §1). Only that condition changes (ADR 0355 §4): a wait for a new login, a module that needs
// the operator and a directory used as found keep their severities, and without the field, or without a manifest
// to read it from, it stays the warning it was.
func declaredSeverity(o *conditions.Observation, hold *holding, module string) {
if o.Kind != kindModuleUnhealthy || hold == nil {
return
}
if m, ok := hold.manifestOf(module); ok && m.UnhealthyIsUrgent() {
o.Severity = conditions.Urgent
}
}
// moduleUnhealthyObservation is a module unhealthy on a machine, in words: the summary names the module,
// the machine and what is wrong with each resource; the detail — targets, streaks, since — is evidence.
func moduleUnhealthyObservation(module, node string, rs []inventory.ResourceHealth) conditions.Observation {
-7
View File
@@ -30,7 +30,6 @@ func userUnit(module, account string) inventory.ResourceHealth {
// Only what depends on the new login alone is a wait; anything else unhealthy of the module is judged as
// before, so that a fault is never excused.
func TestOnlyWhatDependsOnTheNewLoginIsAWait(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
notInGroup := relogin("lights", "operator")
notInGroup.Reason = "not in the group lights: the apply has not put operator there; its outcome says why"
systemUnit := userUnit("lights", "operator")
@@ -75,7 +74,6 @@ func TestOnlyWhatDependsOnTheNewLoginIsAWait(t *testing.T) {
// The gate reads a wait for a person as its own reading, never a fault, and passes with it; the same module
// with a container down beside it is not yet healthy, as before.
func TestAWaitForAPersonIsAPassCarriedAlong(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
now := time.Now()
since := now.Add(-time.Minute)
account := relogin("lights", "operator")
@@ -104,7 +102,6 @@ func TestAWaitForAPersonIsAPassCarriedAlong(t *testing.T) {
// The module's condition says the wait in one sentence for a person, and clears on the first statement that
// no longer says it — said as the module's own fault when its unit still fails after the new login.
func TestTheReloginConditionSaysTheWaitAndClearsAfterTheLogin(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
k, _ := withConditionsInMemory(t)
@@ -161,7 +158,6 @@ func TestTheReloginConditionSaysTheWaitAndClearsAfterTheLogin(t *testing.T) {
// A module that is healthy on its own keeps its pass when another module of the same send fails at the
// bound: it is neither put back nor left without a verdict for every other walk to wait on.
func TestAModuleHealthyOnItsOwnKeepsItsPassWhenItsSendFails(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
b := aBacklog(t)
ctx := t.Context()
inv := b.open.inventory
@@ -211,7 +207,6 @@ func TestAModuleHealthyOnItsOwnKeepsItsPassWhenItsSendFails(t *testing.T) {
// What the operator reads of the wait (ADR 0253, ADR 0254): it needs them, so it is never quiet, and it offers
// no button, since nothing but their own new login can do it.
func TestTheReloginConditionNeedsTheOperatorAndOffersNoButton(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
o := reloginObservation("openrazer", "g14", "relogin needed on g14: …", "operator", []inventory.ResourceHealth{
relogin("openrazer", "operator"), userUnit("openrazer", "operator")})
plainExample(t, o, "openrazer waits for a new login on g14",
@@ -243,7 +238,6 @@ func TestTheReloginConditionNeedsTheOperatorAndOffersNoButton(t *testing.T) {
// When a module not working turns into one waiting for a new login, and back, the clearing line says what it
// became, never that it works again (issue 318 review).
func TestAConditionThatBecameTheOtherKindSaysSoWhenItClears(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
store := conditions.NewInMemory()
@@ -292,7 +286,6 @@ func TestAConditionThatBecameTheOtherKindSaysSoWhenItClears(t *testing.T) {
// Which moves add an account group, read from the builds' manifests (issue 318 review).
func TestOnlyAMoveThatAddsAnAccountGroupCanBringAWait(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
user := func(groups ...any) catalogue.Manifest {
return catalogue.Manifest{Module: "lights", Resources: []map[string]any{{"id": "operator", "type": "user",
"name": "operator", "groups": groups}}}
+6
View File
@@ -620,6 +620,12 @@ var plainWordings = map[string]func(conditions.Observation) words{
Explanation: "The bus reports a listener too slow to keep up, so messages to it are late.",
Resolved: "The listener keeps up again"}
}),
kindBusWatchDead: worded(func(o conditions.Observation) words {
return words{Headline: "The bus's watch of slow clients is down",
Explanation: "The part of the bus that notices clients too slow to keep up has stopped, or cannot see, so " +
"such a client would go unnoticed. It is part of the bus's tools on " + machineOr(o, "the bus's machine") + ".",
Resolved: "Resolved: the bus's watch of slow clients works again"}
}),
kindBusFaultsUnread: worded(func(o conditions.Observation) words {
return words{Headline: "Slow clients of the bus cannot be checked",
Explanation: "The controller could not read what the bus said about its clients' connections, so a " +
-7
View File
@@ -372,13 +372,6 @@ func joinAPlan(ctx context.Context, open *stores, module string) (bool, string,
if !found {
return false, "", nil
}
// **A walk that waits for its delivery's word asks nothing** (novox/hq ADR 0239, issue 754): a rebuild does not
// join it, and its outcome is not the walk's. The walk asks its own tier once it has its word.
if p.Waiting() {
return false, fmt.Sprintf("not joining %s (%s at %s): it waits for %s's word, so this build is not its "+
"outcome; once let go, the walk asks %s itself", p.ID, p.Repository, short(p.Commit), p.Delivery.Awaits,
module), nil
}
entries, err := inv.Catalogued(ctx)
if err != nil {
return false, "", err
+1 -1
View File
@@ -133,7 +133,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
{"the route proxy's program: the route proxy alone", "mesh-controller",
[]string{"examples/route-proxy/main.go"}, "route-proxy", true},
{"a package all three build from: all three", "mesh-controller",
[]string{"internal/broker/broker.go"}, "mesh-controller,route-proxy | build-agent", false},
[]string{"internal/broker/broker.go"}, "mesh-controller | build-agent | route-proxy", false},
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
+9 -11
View File
@@ -145,9 +145,8 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
// Whichever builder runs builds it (novox/hq ADR 0360 §3): built-by orders nothing, as before it ordered.
{what: "built-by does not order", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
{what: "built-by orders", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "worker-of never widens", edges: []inventory.Edge{dep("x", workerOf, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "worker-of orders", edges: []inventory.Edge{dep("x", workerOf, "a")},
@@ -297,18 +296,18 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
[]string{"internal/inventory/migrations/0088-a-build-says-its-build-source.sql"}, read,
"mesh-controller | build-agent", ""},
// A package all three build from: all three; the build seat's holder after the controller whose worker
// it binds (worker-of, issue 206), the proxy beside the controller: built-by orders nothing (ADR 0360 §3).
// it binds (worker-of, issue 206), the proxy after the holder that builds it (built-by).
{"a package all three build from: all three", "mesh-controller",
[]string{"internal/broker/broker.go"}, read, "mesh-controller,route-proxy | build-agent", ""},
[]string{"internal/broker/broker.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
{"a vendored package all three build from: all three", "mesh-controller",
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller,route-proxy | build-agent", ""},
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
{"go.sum: all three", "mesh-controller",
[]string{"go.sum"}, read, "mesh-controller,route-proxy | build-agent", ""},
[]string{"go.sum"}, read, "mesh-controller | build-agent | route-proxy", ""},
{"a file added to the proxy's package: the proxy", "mesh-controller",
[]string{"examples/route-proxy/new.go"}, read, "route-proxy", ""},
// Before any build source is said: as before.
{"no build source said: a README moves all three, as before", "mesh-controller",
[]string{"README.md"}, unsaid, "mesh-controller,route-proxy | build-agent", ""},
[]string{"README.md"}, unsaid, "mesh-controller | build-agent | route-proxy", ""},
// In the catalogue, where they live, each by its own build source.
{"the route proxy's recipe: it alone", "mesh-catalog",
[]string{"modules/route-proxy/Dockerfile"}, read, "route-proxy", ""},
@@ -332,7 +331,7 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
// Files not all said: everything the repository builds, as before.
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "head",
Paths: []string{"README.md"}, PathsTruncated: true}
if got := tiered(reachOfMerge(m, entries, read, edges).Plan.Tiers); got != "mesh-controller,route-proxy | build-agent" {
if got := tiered(reachOfMerge(m, entries, read, edges).Plan.Tiers); got != "mesh-controller | build-agent | route-proxy" {
t.Errorf("a merge whose files were not all said: planned %q, wanted all three", got)
}
}
@@ -453,9 +452,8 @@ func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *test
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true}
// Built-by orders nothing since novox/hq ADR 0360 §3.
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
inventory.EdgeWorkerOf: true}
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
// is a prefix of another.
dirs := []string{"a", "ab", "b", "c", "lib/x", "lib/y", "modules/a", "modules/a/sub"}
+3 -88
View File
@@ -17,7 +17,6 @@ import (
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
@@ -195,7 +194,7 @@ func serve(ctx context.Context) (err error) {
// And what the catalogue decided a build meant. The builder's own result is already handled
// above; this is the other half — the control plane is the only one of the three that knows
// which machines run the thing, so it is the one that acts (novox/hq ADR 0072).
if err := server.Follows(following{open: open}); err != nil {
if err := server.Follows(following{open}); err != nil {
return err
}
// And the merges the bus announced and never handed over, read back on a timer and acted on late
@@ -203,7 +202,7 @@ func serve(ctx context.Context) (err error) {
go catchingUpOnMerges(ctx, open, server)
// And a catalogue that has just started, asking for what it missed. The same type answers
// both: what a build meant and what the builds were are two questions about one record.
if err := server.Answers(following{open: open}); err != nil {
if err := server.Answers(following{open}); err != nil {
return err
}
// And what providers say about consumers they keep failing, kept for `status` (novox/hq ADR
@@ -257,7 +256,7 @@ func serve(ctx context.Context) (err error) {
go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p)
}
}
if err := server.Checks(following{open: open}); err != nil {
if err := server.Checks(following{open}); err != nil {
return err
}
// Composed now and kept current, before the verb that answers from it is served.
@@ -297,8 +296,6 @@ func serve(ctx context.Context) (err error) {
return err
}
defer stopCLI()
// Its read calls write no line each; how many each caller made is said once an hour (novox/hq issue 762).
go sayReadCalls(ctx)
// And says so on the bus (novox/hq ADR 0197): what it serves, as the NATS services protocol asks.
stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
@@ -1389,13 +1386,6 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
if issued > 0 {
fmt.Printf(" issued %d membership(s)\n", issued)
}
// **And what every other machine hears of a module's stream** (novox/hq ADR 0363 §6): a grant the provider's
// setting changed reaches a consumer on a machine this send does not reach. Its membership is issued again
// as last issued, with only what it hears changed, so nothing else of it moves before its own send.
reheard := reissueHearing(ctx, bus, records, where, sent)
if reheard > 0 {
fmt.Printf(" issued %d membership(s) again for what they hear of a module's stream\n", reheard)
}
if len(refused) > 0 {
// Returned, never passed over (novox/hq issue 249): the declarations of the machines they are
// for are not sent, and the rollout that asked is tried again rather than waiting for a push.
@@ -1754,78 +1744,3 @@ func schemaReach() (int, error) {
}
return reach, nil
}
// reissueHearing issues again, on every machine not just sent, the membership of each assignment whose
// `consumes` names a module owning a stream, when what it hears changed: read as last issued from the bus,
// its `hears` replaced, and published. One that was never issued is left to its own send. Failures are said
// and passed over: the send that carries the grant is not the consumer's, and its own next send issues it.
func reissueHearing(ctx context.Context, bus link.OverNATS, records broker.Records, where broker.Placements,
sent []readyNode) int {
reached := map[string]bool{}
for _, s := range sent {
reached[s.node] = true
}
issued := 0
for node, declared := range records.Assigned {
if reached[node] {
continue
}
for _, d := range declared {
if !d.HearsAStream {
continue
}
subject := broker.MembershipSubject(node, d.Module)
last, err := bus.JS.GetLastMsg(broker.AssignmentsStream, subject)
if err != nil {
if !errors.Is(err, nats.ErrMsgNotFound) {
fmt.Printf(" %s on %s: its membership could not be read to issue what it hears again: %v\n",
d.Module, node, err)
}
continue
}
body, changed, err := withHearing(last.Data, broker.HeardIssuedFor(node, d))
if err != nil {
fmt.Printf(" %s on %s: its membership could not be read to issue what it hears again: %v\n",
d.Module, node, err)
continue
}
if !changed {
continue
}
if err := bus.PublishMembership(ctx, node, d.Module, body); err != nil {
fmt.Printf(" %s on %s: what it hears could not be issued again: %v\n", d.Module, node, err)
continue
}
issued++
}
}
return issued
}
// withHearing is a membership as issued with its `hears` replaced, and whether that changed it. Pure.
func withHearing(issued []byte, hears []broker.HeardIssued) ([]byte, bool, error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(issued, &fields); err != nil {
return nil, false, err
}
var had []broker.HeardIssued
if raw, ok := fields["hears"]; ok {
if err := json.Unmarshal(raw, &had); err != nil {
return nil, false, err
}
}
if slices.Equal(had, hears) {
return issued, false, nil
}
if len(hears) == 0 {
delete(fields, "hears")
} else {
raw, err := json.Marshal(hears)
if err != nil {
return nil, false, err
}
fields["hears"] = raw
}
body, err := json.Marshal(fields)
return body, true, err
}
+2 -12
View File
@@ -340,10 +340,7 @@ func cancelAsk(ctx context.Context, js *broker.JetStream, open *stores, seat str
return "", fmt.Errorf("%s is marked cancelled and could not be deleted from the queue (seq %d): %w — a "+
"holder taking it ends it as cancelled", ask.ID, ask.Seq, err)
}
// A check ended because its pull request has a newer head records nothing (novox/hq issue 726): open is nil.
if open != nil {
recordCancelled(ctx, open, ask)
}
recordCancelled(ctx, open, ask)
return cancelSaid(ask, seat, removed), nil
}
@@ -521,9 +518,7 @@ func rebuildCommand(ctx context.Context, args []string) error {
}
// **A module a plan holds unbuilt, or failed, joins that plan** rather than running beside it: the
// plan would ask it again, or stay failed on an outcome a rebuild has replaced. A walk waiting for its word
// is not joined (novox/hq issue 754): the rebuild runs beside it, and says so.
var beside string
// plan would ask it again, or stay failed on an outcome a rebuild has replaced.
if module != "" {
joined, said, err := joinAPlan(ctx, open, module)
if err != nil {
@@ -533,7 +528,6 @@ func rebuildCommand(ctx context.Context, args []string) error {
fmt.Println(said)
return nil
}
beside = said
}
id, err := askABuild(ctx, source, path, ref)
if err != nil {
@@ -541,10 +535,6 @@ func rebuildCommand(ctx context.Context, args []string) error {
}
recordAsked(ctx, inventory.BuildRequest{ID: id, Repository: source.Repository, Seat: source.Seat, Path: path,
Ref: ref, For: "rebuild"})
if beside != "" {
fmt.Printf("rebuild asked as %s, beside the walk: %s\n", id, beside)
return nil
}
fmt.Printf("rebuild asked as %s\n", id)
return nil
}
+2 -4
View File
@@ -616,7 +616,6 @@ func TestKillAndPauseAskTheHolderOnTheMachine(t *testing.T) {
// A plan that stopped at its first machine is retried: the module sent to that machine again, the
// send recorded as the first anew, and the plan goes on — unless a newer plan holds the module.
func TestAPlanStoppedAtItsFirstMachineIsRetried(t *testing.T) {
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
asked := asksRecorded(t)
@@ -637,9 +636,8 @@ func TestAPlanStoppedAtItsFirstMachineIsRetried(t *testing.T) {
stopped := inventory.Plan{ID: "plan-rollout", Repository: "novox/a", Branch: "main", Commit: "c0ffee",
Created: long, State: inventory.PlanFailed, Tiers: [][]string{{"a"}, {"b"}},
Note: "a stopped at its first machine in tier 0: laptop refused what it was sent",
// Asked by the plan, as every module a plan holds built is (novox/hq issue 754).
Modules: map[string]*inventory.PlanModule{"a": {State: "built", AskedAt: &long, Build: "build-a", BuiltAt: &long,
Commit: "c0ffee", First: []string{"laptop"}, FirstAt: &long, Why: "laptop refused what it was sent"}}}
Modules: map[string]*inventory.PlanModule{"a": {State: "built", BuiltAt: &long, Commit: "c0ffee",
First: []string{"laptop"}, FirstAt: &long, Why: "laptop refused what it was sent"}}}
if err := open.inventory.SavePlan(ctx, &stopped); err != nil {
t.Fatal(err)
}
@@ -1,39 +0,0 @@
package main
import (
"encoding/json"
"testing"
"github.com/novox/mesh-controller/internal/broker"
)
// A membership issued again for what it hears changes nothing else of it (novox/hq ADR 0363 §6).
func TestAMembershipIssuedAgainChangesOnlyWhatItHears(t *testing.T) {
issued := []byte(`{"node":"ace","module":"reader","receives":{"x":[1]},"emits":"mesh.mod.reader.event.{event}"}`)
hears := []broker.HeardIssued{{Stream: "MAIL", Consumer: "ace_reader"}}
body, changed, err := withHearing(issued, hears)
if err != nil || !changed {
t.Fatalf("changed %v, %v", changed, err)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got["receives"] == nil || got["emits"] != "mesh.mod.reader.event.{event}" || got["hears"] == nil {
t.Fatalf("issued again as %s", body)
}
if _, again, _ := withHearing(body, hears); again {
t.Fatal("the same hearing issued again counts as a change")
}
gone, changed, err := withHearing(body, nil)
if err != nil || !changed {
t.Fatalf("a withdrawal: %v, %v", changed, err)
}
got = nil
if err := json.Unmarshal(gone, &got); err != nil {
t.Fatal(err)
}
if _, still := got["hears"]; still {
t.Fatalf("a withdrawn hearing is still issued: %s", gone)
}
}
+1 -32
View File
@@ -750,11 +750,6 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
if r.Rest {
return advanceRest(ctx, open, p, now)
}
if r.Gate == nil && !gateFirstMachine {
// **No first machine** (novox/hq ADR 0360 §7): every machine heard from is sent at once, in one step.
r.Rest = true
return advanceRest(ctx, open, p, now)
}
if r.Gate == nil {
heard, err := releaseHeard(ctx, open)
if err != nil {
@@ -1020,25 +1015,6 @@ func advanceRest(ctx context.Context, open *stores, p *inventory.Plan, now time.
judging = append(judging, node+": not yet reported on its send")
continue
}
if !gateFirstMachine {
// **Judged by its report alone** (novox/hq ADR 0360 §7): applied passes; failed or refused is named and
// fails the walk, with nothing put back — apply-failing says it.
switch rep := byNode[node]; rep.Outcome {
case inventory.OutcomeApplied:
decide(g, inventory.GatePassed, node+" reported it applied its send (novox/hq ADR 0360)", now)
passCarried(ctx, open, p, g, "")
r.Done = append(r.Done, node)
changed = true
case inventory.OutcomeFailed, inventory.OutcomeRefused:
decide(g, inventory.GateFailed, fmt.Sprintf("%s %s its send", node, rep.Outcome), now)
r.Failed = append(r.Failed, node)
changed = true
notes = append(notes, fmt.Sprintf("%s %s its send: nothing put back, apply-failing says it", node, rep.Outcome))
default:
judging = append(judging, node+": not yet reported on its send")
}
continue
}
verdict, err := judgeMoves(ctx, open, g, nil, now)
if err != nil {
return changed, err
@@ -1123,9 +1099,6 @@ func advanceRest(ctx context.Context, open *stores, p *inventory.Plan, now time.
// keptStepGrew says why a step's kept sends would now carry a build no gate passed that they were not kept with;
// empty when none would.
func keptStepGrew(ctx context.Context, open *stores, r *inventory.PlanRelease, pending []string) (string, error) {
if !gateFirstMachine {
return "", nil // every machine goes at once (novox/hq ADR 0360 §7): a send carries what waits when it is made
}
f, err := readMoveFacts(ctx, open.inventory)
if err != nil {
return "", err
@@ -1234,11 +1207,7 @@ func openRestStep(ctx context.Context, open *stores, p *inventory.Plan) (bool, s
if err != nil {
return false, "", err
}
// **Every machine at once** (novox/hq ADR 0360 §2, §7): no build waits for one machine to pass first.
step := nodes
if gateFirstMachine {
step = restStep(nodes, waiting, f.gated, func(module string) bool { return oneAtATime(module, shelf[module]) })
}
step := restStep(nodes, waiting, f.gated, func(module string) bool { return oneAtATime(module, shelf[module]) })
if len(step) == 0 {
if len(walked) > 0 {
return false, "waiting before " + strings.Join(walked, "; "), nil
-273
View File
@@ -1,273 +0,0 @@
package main
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// **A pull request into a release branch is judged by that branch** (novox/hq ADR 0358, issue 704): a base that is
// not its repository's trunk, where mesh-controller has a branch of that name, is a release branch; the check then
// reads every repository beside it at its branch of that name where it has one, else at its trunk. Any other base is
// checked exactly as before.
const aRelease = "feat/57-config-and-provisioning"
// theCore is the mesh's core as a check reads it beside: the controller, the node-engine, the tool runner and the
// catalogue's bus, on the git seat.
func theCore() []inventory.Entry {
onSeat := func(module, repository string) inventory.Entry {
e := fromRepo(module, repository, "")
e.Source.Seat = "git"
return e
}
nats := onSeat("nats", "novox/mesh-catalog")
nats.Source.Path = "modules/nats"
return []inventory.Entry{onSeat("mesh-controller", "novox/mesh-controller"), onSeat("mesh-host", "novox/mesh-host"),
onSeat("node-tools", "novox/mesh-tools"), nats, onSeat("gitea", "novox/mesh-catalog")}
}
// aForgeWithBranches stands in for the forge: which branches each repository has, and how often it was asked.
func aForgeWithBranches(t *testing.T, branches map[string][]string, failing error) *int {
t.Helper()
asked := 0
was := forgeHasBranch
t.Cleanup(func() { forgeHasBranch = was })
forgeHasBranch = func(_ context.Context, owner, repo, branch string) (bool, error) {
asked++
if failing != nil {
return false, failing
}
for _, b := range branches[owner+"/"+repo] {
if b == branch {
return true, nil
}
}
return false, nil
}
return &asked
}
func TestABaseIsAReleaseBranchWhenTheControllerHasABranchOfThatName(t *testing.T) {
entries := theCore()
scopeOf := func(p link.PullUpdated) checkScope { return pullScope(p, entries, nil, nil) }
pull := func(repo, base string) link.PullUpdated {
return link.PullUpdated{Owner: "novox", Repo: repo, Number: 286, Base: base, Commit: "abc",
Paths: []string{"modules/gitea/index.ts"}}
}
asked := aForgeWithBranches(t, map[string][]string{"novox/mesh-controller": {"main", aRelease}}, nil)
for _, c := range []struct {
what string
p link.PullUpdated
release string
asks int
}{
{"a pull request into the trunk asks nothing and is no release", pull("mesh-catalog", "main"), "", 0},
{"a catalogue pull request into the release branch", pull("mesh-catalog", aRelease), aRelease, 1},
{"a controller pull request into the release branch", pull("mesh-controller", aRelease), aRelease, 1},
{"a base mesh-controller has no branch of is checked as today", pull("mesh-catalog", "feat/other"), "", 1},
{"no base: as today", pull("mesh-catalog", ""), "", 0},
} {
*asked = 0
got, err := releaseBranchOf(t.Context(), c.p, scopeOf(c.p), entries)
if err != nil {
t.Fatalf("%s: %v", c.what, err)
}
if got != c.release || *asked != c.asks {
t.Errorf("%s: release %q after %d question(s) to the forge, not %q after %d", c.what, got, *asked,
c.release, c.asks)
}
}
// The branch a module built from the repository follows is its trunk: never asked about.
photos := fromRepo("photos", "novox/photos", "")
photos.Source.Ref = "nox-mesh"
withPhotos := append(theCore(), photos)
p := link.PullUpdated{Owner: "novox", Repo: "photos", Base: "nox-mesh", Commit: "abc", Paths: []string{"x.js"}}
*asked = 0
if got, err := releaseBranchOf(t.Context(), p, pullScope(p, withPhotos, nil, nil), withPhotos); err != nil ||
got != "" || *asked != 0 {
t.Fatalf("a module's own trunk read as release %q (%v) after %d question(s)", got, err, *asked)
}
// A forge that cannot answer is an error, never "no release branch".
aForgeWithBranches(t, nil, errors.New("nothing serves gitea.gitea_api"))
q := pull("mesh-catalog", aRelease)
if _, err := releaseBranchOf(t.Context(), q, scopeOf(q), entries); err == nil ||
!strings.Contains(err.Error(), "cannot be told") {
t.Fatalf("an unanswered forge was read as an answer: %v", err)
}
}
func TestBesideAReleaseBranchCheckEachRepositoryIsReadAtThatBranchElseItsTrunk(t *testing.T) {
entries := theCore()
clone := func(s inventory.Source) (string, error) { return "http://git/" + s.Repository + ".git", nil }
running := func(module string) string { return "running-" + module }
beside, err := besideOf(entries, running, clone, aRelease)
if err != nil {
t.Fatal(err)
}
at := func(repo string, ref, orElse string) link.CheckedOut {
return link.CheckedOut{Repository: "http://git/novox/" + repo + ".git", Ref: ref, Else: orElse}
}
want := map[string]link.CheckedOut{
// The judge: mesh-controller's release branch, which it has.
"mesh-controller": at("mesh-controller", aRelease, ""),
"mesh-controller-main": at("mesh-controller", "main", ""),
// A controller pull request into the release branch reads mesh-host at that branch where it has one.
"mesh-host": at("mesh-host", aRelease, "main"),
"mesh-tools": at("mesh-tools", aRelease, "main"),
"mesh-catalog": at("mesh-catalog", aRelease, "main"),
// mesh-lab at its trunk, always: its replays run with the container runtime's socket.
"mesh-lab": at("mesh-lab", "main", ""),
"mesh-sdk": at("mesh-sdk", aRelease, "main"),
}
if !reflect.DeepEqual(beside, want) {
t.Fatalf("beside a release-branch check:\n got %v\nwant %v", beside, want)
}
// Any other base: exactly as before — what the mesh runs, the catalogue's and the lab's main.
beside, err = besideOf(entries, running, clone, "")
if err != nil {
t.Fatal(err)
}
want = map[string]link.CheckedOut{
"mesh-controller": at("mesh-controller", "running-mesh-controller", ""),
"mesh-controller-main": at("mesh-controller", "main", ""),
"mesh-host": at("mesh-host", "running-mesh-host", ""),
"mesh-tools": at("mesh-tools", "running-node-tools", ""),
"mesh-catalog": at("mesh-catalog", "main", ""),
"mesh-lab": at("mesh-lab", "main", ""),
"mesh-sdk": at("mesh-sdk", sdkPinned(), ""),
}
if !reflect.DeepEqual(beside, want) {
t.Fatalf("beside a trunk check:\n got %v\nwant %v", beside, want)
}
}
func TestAReleaseBranchVerdictNamesTheBranchTheJudgeAndWhatItRead(t *testing.T) {
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: aRelease, Release: aRelease}
c := checkedOf(link.BuildResult{ID: "b", Ref: "aaaaaaaaaaaa0000", Checked: asked, Check: &link.CheckOutcome{
Verdict: "pass", Head: "aaaaaaaaaaaa", Trunk: "111111111111", Gate: &link.CheckLayer{Verdict: "pass"},
RepoCheck: &link.CheckLayer{Verdict: "pass"}, Release: aRelease, JudgedBy: "fcdecb436117",
Beside: map[string]string{"mesh-host": "222222222222", "mesh-lab": "333333333333"},
BesideAt: map[string]string{"mesh-host": aRelease, "mesh-lab": "main"},
Validator: "mesh-host's validator of " + aRelease + " at 222222222222"}})
if c.Release != aRelease || c.JudgedBy != "fcdecb436117" {
t.Fatalf("the verdict names release %q judged by %q", c.Release, c.JudgedBy)
}
if c.BesideAt["mesh-host"] != aRelease || c.BesideAt["mesh-lab"] != "main" || c.Beside["mesh-host"] != "222222222222" ||
c.Validator != "mesh-host's validator of "+aRelease+" at 222222222222" {
t.Fatalf("the verdict names what it read beside it as %v at %v", c.Beside, c.BesideAt)
}
}
// **Clause 5** (ADR 0358 §5): a release branch's own pull request into its trunk is no release-branch check: the forge
// is asked nothing, the request names no release branch, and the repositories beside it are read as for any trunk
// pull request. Its delivery group's composed check is asked as for any other (askGroupCheck, unchanged).
func TestAReleaseBranchsPullRequestIntoItsTrunkIsCheckedAsAnyTrunkPullRequest(t *testing.T) {
entries := theCore()
asked := aForgeWithBranches(t, map[string][]string{"novox/mesh-controller": {"main", aRelease}}, nil)
for _, repo := range []string{"mesh-catalog", "mesh-controller", "mesh-host"} {
p := link.PullUpdated{Owner: "novox", Repo: repo, Number: 400, Head: aRelease, Base: "main", Commit: "abc",
Paths: []string{"modules/gitea/index.ts"}}
got, err := releaseBranchOf(t.Context(), p, pullScope(p, entries, nil, nil), entries)
if err != nil || got != "" || *asked != 0 {
t.Fatalf("%s's release branch into main read as release %q (%v) after %d question(s)", repo, got, err, *asked)
}
}
clone := func(s inventory.Source) (string, error) { return "http://git/" + s.Repository + ".git", nil }
beside, err := besideOf(entries, func(module string) string { return "running-" + module }, clone, "")
if err != nil {
t.Fatal(err)
}
for dir, b := range beside {
if b.Ref == aRelease || b.Else != "" {
t.Fatalf("beside a trunk pull request %s is read at %+v", dir, b)
}
}
}
// **A forge answer that names no branch is an error**, never "no release branch"; a 404 is "no such branch".
func TestTheForgesAnswerAboutABranchIsReadStrictly(t *testing.T) {
for _, c := range []struct {
what string
answer link.Answer
has bool
fails bool
}{
{"the branch named", link.Answer{Result: []byte(`{"result":{"name":"` + aRelease + `"}}`)}, true, false},
{"not found", link.Answer{Error: "Gitea API /repos/novox/mesh-controller/branches/x: 404 {\"message\":\"not found\"}"},
false, false},
{"an empty branch name", link.Answer{Result: []byte(`{"result":{"name":""}}`)}, false, true},
{"no result", link.Answer{Result: []byte(`{}`)}, false, true},
{"another branch named", link.Answer{Result: []byte(`{"result":{"name":"main"}}`)}, false, true},
{"unreadable", link.Answer{Result: []byte(`not json`)}, false, true},
{"another error", link.Answer{Error: "Gitea API: 500 internal"}, false, true},
} {
has, err := branchAnswered(c.answer, aRelease)
if has != c.has || (err != nil) != c.fails {
t.Errorf("%s: has %v, error %v", c.what, has, err)
}
}
}
// **The trunk is the repository's own, not a name**: a repository whose trunk is not main, one that follows main, and
// a controller whose trunk is not main.
func TestATrunkIsTheBranchTheRepositoryFollows(t *testing.T) {
asked := aForgeWithBranches(t, map[string][]string{"novox/mesh-controller": {"main", aRelease, "master"}}, nil)
legacy := fromRepo("legacy", "novox/legacy", "")
legacy.Source.Ref = "master"
onMain := fromRepo("photos", "novox/photos", "")
onMain.Source.Ref = "main"
entries := append(theCore(), legacy, onMain)
for _, c := range []struct {
what, repo, base, release string
asks int
}{
{"a repository whose trunk is master, into master", "legacy", "master", "", 0},
{"a repository whose trunk is master, into the release branch", "legacy", aRelease, aRelease, 1},
{"a repository whose module follows main, into main", "photos", "main", "", 0},
{"a repository whose module follows main, into the release branch", "photos", aRelease, aRelease, 1},
} {
*asked = 0
p := link.PullUpdated{Owner: "novox", Repo: c.repo, Base: c.base, Commit: "abc", Paths: []string{"x"}}
got, err := releaseBranchOf(t.Context(), p, pullScope(p, entries, nil, nil), entries)
if err != nil || got != c.release || *asked != c.asks {
t.Errorf("%s: release %q (%v) after %d question(s), not %q after %d", c.what, got, err, *asked, c.release, c.asks)
}
}
// A controller whose trunk is not main: main may be a release branch there, and its own trunk never is.
core := theCore()
core[0].Source.Ref = "trunk"
for _, c := range []struct {
base, release string
asks int
}{{"trunk", "", 0}, {"main", "main", 1}} {
*asked = 0
p := link.PullUpdated{Owner: "novox", Repo: "hq", Base: c.base, Commit: "abc", Paths: []string{"x"}}
got, err := releaseBranchOf(t.Context(), p, pullScope(p, core, nil, nil), core)
if err != nil || got != c.release || *asked != c.asks {
t.Errorf("hq into %s, the controller following trunk: release %q (%v) after %d question(s)", c.base, got, err,
*asked)
}
}
}
// trunkOf: the branch a source follows, and main when it records none or records a commit (issue 215's rule).
func TestATrunkIsMainWhenTheSourceRecordsNone(t *testing.T) {
for ref, want := range map[string]string{"": "main", "main": "main", "master": "master", "nox-mesh": "nox-mesh",
"0123456789abcdef0123456789abcdef01234567": "main", " ": "main"} {
if got := trunkOf(inventory.Source{Ref: ref}); got != want {
t.Errorf("a source recording %q follows %q, not %q", ref, got, want)
}
}
}
+84 -230
View File
@@ -9,13 +9,11 @@ import (
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
@@ -28,10 +26,6 @@ import (
// alone cannot (a tier waiting for machines to report); a controller replaced mid-plan finds the
// plan where it left it.
// gateFirstMachine sends a module that rolls out to one machine first and judges it there (ADR 0236). Off since
// novox/hq ADR 0360: the gate's code is kept for the walks a controller before it started, and its tests turn it on.
var gateFirstMachine = false
// planWaitBound is how long a plan may wait on one thing before `status` names it red.
const planWaitBound = 30 * time.Minute
@@ -48,15 +42,29 @@ func tiersOf(set []string, edges []inventory.Edge) [][]string {
for _, m := range set {
deps[m] = map[string]bool{}
}
// The build seat's holders follow the controller that defines their worker (EdgeWorkerOf,
// novox/hq issue 206), so the built-by edge from that controller to such a holder yields: the
// controller is built by whichever build machine is running, as the runtime image always was.
worker := map[string]map[string]bool{}
for _, e := range edges {
if e.Kind == inventory.EdgeWorkerOf && in[e.From] && in[e.To] {
if worker[e.To] == nil {
worker[e.To] = map[string]bool{}
}
worker[e.To][e.From] = true
}
}
for _, e := range edges {
// A code dependency — B packages A's source — rebuilds B with A, in the same tier: B's
// build needs nothing of A's first. Stands-on and declared order after the base is built, and the
// build seat's holder follows the controller that defines its worker (EdgeWorkerOf, issue 206).
// build needs nothing of A's first. The other kinds order: stands-on and declared after
// the base is built, built-by after the build machine is built and running — except for
// what the build machine itself stands on, and for the controller whose worker the build
// machine binds. The runtime image is built by the builder and the builder is built on the
// runtime image; the image comes first, built by the builder that is running.
if !in[e.From] || !in[e.To] || e.From == e.To || e.Kind == inventory.EdgePackages {
continue
}
// **Being built by the builder orders nothing** (novox/hq ADR 0360 §3): whichever builder runs builds it.
if e.Kind == inventory.EdgeBuiltBy {
if e.Kind == inventory.EdgeBuiltBy && (isBaseOf(e.From, e.To, edges, in) || worker[e.From][e.To]) {
continue
}
deps[e.From][e.To] = true
@@ -97,6 +105,29 @@ func tiersOf(set []string, edges []inventory.Edge) [][]string {
return tiers
}
// isBaseOf says whether `to` stands on `base`, directly or through other bases in the set, along
// the build edges alone.
func isBaseOf(base, to string, edges []inventory.Edge, in map[string]bool) bool {
seen := map[string]bool{}
var walk func(string) bool
walk = func(m string) bool {
if m == base {
return true
}
if seen[m] {
return false
}
seen[m] = true
for _, e := range edges {
if e.From == m && in[e.To] && (e.Kind == inventory.EdgeStandsOn || e.Kind == inventory.EdgeDeclared) && walk(e.To) {
return true
}
}
return false
}
return walk(to)
}
// reachableFrom is the moved modules plus everything that depends on them, through every layer:
// what a merge rebuilds. Along the code and build edges only: a module *built by* the build machine
// is not changed by a new build machine, so a built-by edge orders and gates a plan and never
@@ -132,7 +163,7 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
}
// hasCycle says whether the tiers' last tier holds modules that still depend on each other. A packages
// or built-by edge orders nothing (tiersOf), so a module and what packages its source share a tier by rule: that is
// edge orders nothing (tiersOf), so a module and what packages its source share a tier by rule: that is
// no cycle, and saying one was is a false report in every such plan's log.
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
if len(tiers) == 0 {
@@ -143,7 +174,7 @@ func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
last[m] = true
}
for _, e := range edges {
if e.Kind == inventory.EdgePackages || e.Kind == inventory.EdgeBuiltBy {
if e.Kind == inventory.EdgePackages {
continue
}
if last[e.From] && last[e.To] {
@@ -189,6 +220,12 @@ func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool)
return nil
}
inTier := map[string]bool{}
all := map[string]bool{}
for _, tier := range p.Tiers {
for _, m := range tier {
all[m] = true
}
}
for _, m := range p.Tiers[p.Tier] {
inTier[m] = true
}
@@ -203,8 +240,8 @@ func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool)
for _, e := range edges {
// A delivery group's order inside a walk (novox/hq ADR 0276 decision 5) gates as built-by does: the
// member before is running on its machines before the member after is asked.
// A built-by edge waits for nothing since novox/hq ADR 0360 §3.
ordered := e.Kind == edgeGroupOrder
ordered := e.Kind == edgeGroupOrder ||
e.Kind == inventory.EdgeBuiltBy && !isBaseOf(e.From, e.To, edges, all)
if later[e.From] && inTier[e.To] && ordered && !seen[e.To] && rollsOut(e.To) {
seen[e.To] = true
out = append(out, e.To)
@@ -259,11 +296,10 @@ func applied(module string, builtAt time.Time, running []string, reports []inven
return len(waiting) == 0, waiting
}
// askTier asks the build machine for the named modules of the plan's tier — all of it, or what of it is still
// unasked (novox/hq issue 754) — and marks each asked. A module
// askTier asks the build machine for every module of the tier, and marks each asked. A module
// the catalogue no longer holds, or whose ask could not be made, is a failure of the plan: a tier
// half asked is a tier that will never complete.
func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan, names []string) error {
func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) error {
entries, err := inv.Catalogued(ctx)
if err != nil {
return err
@@ -272,62 +308,12 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan, n
for _, e := range entries {
byName[e.Manifest.Module] = e
}
for _, name := range names {
for _, name := range p.Tiers[p.Tier] {
askModule(ctx, p, name, byName)
}
return nil
}
// unaskedOf are the modules of a plan's current tier it has still to ask: those it never asked, and one it holds
// built that it never asked for (novox/hq issue 754) — a build taken in by a controller from before a walk took
// only its own asks, somebody else's, which the walk would otherwise send as its own (ADR 0219). A tier partly asked is asked the rest, not
// waited on for ever: a walk that waits for every build of a tier it never asked whole never moves again.
func unaskedOf(p inventory.Plan) []string {
if p.Tier >= len(p.Tiers) {
return nil
}
var out []string
for _, m := range p.Tiers[p.Tier] {
s := p.Modules[m]
switch {
case s == nil || s.State == "":
out = append(out, m)
case s.State == "built" && s.Build == "" && s.AskedAt == nil:
out = append(out, m)
}
}
return out
}
// askedInTier says the plan asked a module of its current tier itself: under an id, or at a time it kept.
func askedInTier(p inventory.Plan) bool {
if p.Tier >= len(p.Tiers) {
return false
}
for _, m := range p.Tiers[p.Tier] {
if s := p.Modules[m]; s != nil && (s.Build != "" || s.AskedAt != nil) {
return true
}
}
return false
}
// takesOutcome says whether a build's outcome is the plan's for a module of its current tier (novox/hq ADR 0219,
// issues 219 and 754). **A walk that waits for its delivery's word asks nothing, and so is answered by nothing**
// (ADR 0239): a rebuild that lands in the wait is somebody else's build. **Nor is a module the plan never asked
// answered**: taken, the plan held a module built that it never asked, and waited for the rest of the tier for
// ever. Asked under an id, the plan takes that id's outcome and no other; asked before ids were kept, a build
// asked at or after the plan's ask.
func takesOutcome(p inventory.Plan, state *inventory.PlanModule, asked time.Time, id string) bool {
if p.Waiting() || state == nil || (state.Build == "" && state.AskedAt == nil) {
return false
}
if state.Build != "" {
return state.Build == id
}
return !askedBefore(asked, state.AskedAt)
}
// askABuild is how a plan asks for one build, not waited for, and learns the id it asked under. A
// variable so a test of what a plan does around an ask needs no build machine.
//
@@ -400,73 +386,15 @@ var checkedBuildsOf = func(ctx context.Context, pulls []link.PullRef) ([]link.Ch
return link.ReadCheckBuilds(ctx, js, buildSeatHeld(ctx), pulls, time.Now().Add(-builder.CheckBuildsKept))
}
// mergedHeadsOf are the head commits the walk's merges merged, by pull request (novox/hq issue 703): a pull request
// merged twice in one walk, or whose head the announcement did not say, has none, and takes no check's build.
func mergedHeadsOf(p *inventory.Plan) map[link.PullRef]string {
heads := map[link.PullRef]string{}
if p.Delivery == nil {
return heads
}
seen := map[link.PullRef]bool{}
for _, m := range p.Delivery.Merges {
pull, ok := pullOf(m)
if !ok {
continue
}
if seen[pull] && heads[pull] != m.Head {
heads[pull] = ""
continue
}
seen[pull] = true
heads[pull] = m.Head
}
return heads
}
// walkVerdicts are the checks' builds a walk read, by the walk's plan: read once per walk (novox/hq issue 649), not
// once per module it asks for. Kept a day; a walk lasts minutes.
var walkVerdicts = struct {
sync.Mutex
read map[string]verdictsRead
}{read: map[string]verdictsRead{}}
type verdictsRead struct {
at time.Time
builds []link.CheckedBuild
}
// reuseWithVerdicts is a walk's leave with the builds its pull requests' verdicts name: the only builds the builder
// may take (ADR 0350 §2), and **only those of a check of the head each merge merged** (novox/hq issue 703): a build
// a check made at an earlier head of the same pull request is never registered. Read once per walk. When they
// cannot be read the leave names none, and the walk builds, saying why.
func reuseWithVerdicts(ctx context.Context, walk string, r *link.ReuseAsk, heads map[link.PullRef]string) *link.ReuseAsk {
walkVerdicts.Lock()
read, known := walkVerdicts.read[walk]
walkVerdicts.Unlock()
if !known || walk == "" {
builds, err := checkedBuildsOf(ctx, r.Pulls)
if err != nil {
fmt.Printf(" the checks' builds of %d pull request(s) could not be read, so the walk builds: %v\n", len(r.Pulls), err)
return r
}
read = verdictsRead{at: time.Now(), builds: builds}
if walk != "" {
walkVerdicts.Lock()
for id, w := range walkVerdicts.read {
if time.Since(w.at) > 24*time.Hour {
delete(walkVerdicts.read, id)
}
}
walkVerdicts.read[walk] = read
walkVerdicts.Unlock()
}
}
r.Builds = nil
for _, b := range read.builds {
if link.SameHead(b.Head, heads[b.Pull]) {
r.Builds = append(r.Builds, b)
}
// may take (ADR 0350 §2). When they cannot be read the leave names none, and the walk builds, saying why.
func reuseWithVerdicts(ctx context.Context, r *link.ReuseAsk) *link.ReuseAsk {
builds, err := checkedBuildsOf(ctx, r.Pulls)
if err != nil {
fmt.Printf(" the checks' builds of %d pull request(s) could not be read, so the walk builds: %v\n", len(r.Pulls), err)
return r
}
r.Builds = builds
return r
}
@@ -509,8 +437,7 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
}
// **The walk takes the check's build of the same key** (novox/hq ADR 0350 §2): its ask says so, with the pull
// requests it answers, whose checks name the part of a differing key.
id, err := askABuild(withReuse(ctx, reuseWithVerdicts(ctx, p.ID, reuseAskOf(p), mergedHeadsOf(p))), source,
e.Source.Path, ref)
id, err := askABuild(withReuse(ctx, reuseWithVerdicts(ctx, reuseAskOf(p))), source, e.Source.Path, ref)
if err != nil {
state.State = "failed"
state.Why = err.Error()
@@ -577,15 +504,22 @@ func planBuiltSaying(ctx context.Context, open *stores, module, commit, failed s
if !inTier {
continue
}
state := p.Modules[module]
if state == nil {
state = &inventory.PlanModule{}
p.Modules[module] = state
}
// **The plan's own ask is its outcome, by id** (novox/hq ADR 0219); another build of the module
// is, as before, when it was asked at or after the plan's ask (issue 219).
// **A module asked under an id is answered by that id's outcome and no other** (novox/hq ADR
// 0219): a replay, a rebuild beside the plan, or an older ask finishing late is somebody else's
// build, made from other source, and settling the plan with it would send that. A plan from
// before ids were kept is matched as it was: by when the build was asked (issue 219). A walk
// waiting for its word, or a module it never asked, takes no outcome at all (issue 754).
state := p.Modules[module]
if !takesOutcome(*p, state, asked, id) {
// before ids were kept is matched as it was: by when the build was asked (issue 219).
if state.Build != "" {
if state.Build != id {
continue
}
} else if askedBefore(asked, state.AskedAt) {
continue
}
if failed != "" && deletedAtSource(failed) {
@@ -791,11 +725,17 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
return true, nil
}
tier := p.Tiers[p.Tier]
// Not yet asked: ask — the whole tier, or what of it is still unasked (novox/hq issue 754).
if unasked := unaskedOf(*p); len(unasked) > 0 {
// Not yet asked: ask.
unasked := 0
for _, m := range tier {
if s := p.Modules[m]; s == nil || s.State == "" {
unasked++
}
}
if unasked == len(tier) {
// **One walk at a time** (novox/hq ADR 0276): a walk about to ask its first tier while another started
// walk is open waits for that one to end, let go or not, and says so.
if p.Tier == 0 && !askedInTier(*p) {
if p.Tier == 0 {
if behind, err := startedBeside(ctx, inv, p.ID, p.Created); err != nil {
return false, err
} else if behind != "" {
@@ -805,10 +745,7 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
return changed, nil
}
}
if len(unasked) < len(tier) {
fmt.Printf("%s: tier %d was asked in part; asking the rest: %s\n", p.ID, p.Tier, strings.Join(unasked, ", "))
}
if err := askTier(ctx, inv, p, unasked); err != nil {
if err := askTier(ctx, inv, p); err != nil {
return false, err
}
return true, nil
@@ -891,12 +828,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
if err != nil {
return false, err
}
// **Every machine at once** (novox/hq ADR 0360 §1, §2): a module that rolls out is sent to every machine
// running it in one step, with no first machine and no gate, whatever module it is. A module a controller
// before ADR 0360 already sent to its first machine finishes under its gate.
if !gateFirstMachine && state.FirstAt == nil {
policy.Together = true
}
var reports []inventory.Reported
if !policy.Together {
// Read for the choice of the first machine as well as for its report.
@@ -1126,92 +1057,15 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
return changed, nil
}
}
// **A walk ends on its machines' reports** (novox/hq ADR 0360 §4, §5): after its last tier, every machine it sent
// has reported on that send, or is silent past ApplySilentAfter; never a health reading.
end := ""
if p.Tier == len(p.Tiers)-1 {
reports, err := inv.LastReports(ctx)
if err != nil {
return false, err
}
waiting, failed, silent := walkReports(*p, reports, time.Now().UTC())
if len(waiting) > 0 {
note := "sent; waiting for " + strings.Join(waiting, ", ") + " to report on their send"
changed := p.State != inventory.PlanRolling || p.Note != note
p.State = inventory.PlanRolling
p.Note = note
return changed, nil
}
var said []string
if len(failed) > 0 {
said = append(said, "failed or refused its send (apply-failing says it): "+strings.Join(failed, ", "))
}
if len(silent) > 0 {
said = append(said, fmt.Sprintf("said nothing on its send within %s: %s", inventory.ApplySilentAfter,
strings.Join(silent, ", ")))
}
end = strings.Join(said, "; ")
// **The new controller's user list follows its walk** (ADR 0236 §34, read without the gate since ADR 0360):
// once a walk that moved the controller ends, the bus's machine is sent the list it composes, if behind.
if s := p.Modules[catalogue.ControllerSeatName]; s != nil && s.SentAt != nil && s.Gate == nil && !noMove(s) {
carryUserList(ctx, open, p)
}
}
p.Tier++
p.State = inventory.PlanBuilding
p.Note = end
p.Note = ""
if p.Tier < len(p.Tiers) {
fmt.Printf("%s: tier %d done; asking tier %d: %s\n", p.ID, p.Tier-1, p.Tier, strings.Join(p.Tiers[p.Tier], ", "))
}
return true, nil
}
// walkReports is, for every machine a walk sent its rest, whether it reported on that send (novox/hq ADR 0360 §4):
// those still waiting, those that reported a failed or refused apply, and those silent past ApplySilentAfter,
// each sorted. Pure.
func walkReports(p inventory.Plan, reports []inventory.Reported, now time.Time) (waiting, failed, silent []string) {
byNode := map[string]inventory.Reported{}
for _, r := range reports {
byNode[r.Node] = r
}
seen := map[string]bool{}
for _, s := range p.Modules {
if s == nil {
continue
}
for node, sent := range s.Rest {
if seen[node] {
continue
}
r := byNode[node]
at := sent.At
if at.IsZero() && s.SentAt != nil {
at = *s.SentAt
}
switch {
case r.At != nil && sent.ReportsOn(r):
seen[node] = true
if r.Outcome == inventory.OutcomeFailed || r.Outcome == inventory.OutcomeRefused {
failed = append(failed, node)
}
case !at.IsZero() && now.Sub(at) > inventory.ApplySilentAfter:
seen[node] = true
silent = append(silent, node)
default:
if !slices.Contains(waiting, node) {
waiting = append(waiting, node)
}
}
}
}
// A machine one module's send was reported on and another's not yet is the same send: reported.
waiting = slices.DeleteFunc(waiting, func(n string) bool { return seen[n] })
sort.Strings(waiting)
sort.Strings(failed)
sort.Strings(silent)
return waiting, failed, silent
}
// restOf is, for one module, every machine of its rest that the send reached and what it carried there.
func restOf(to, sent []string, what map[string]inventory.SentDeclaration) map[string]inventory.SentDeclaration {
out := map[string]inventory.SentDeclaration{}
+12 -14
View File
@@ -1,7 +1,6 @@
package main
import (
"slices"
"testing"
"time"
@@ -50,10 +49,8 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
if pos["mesh-tools"] != 0 || pos["builder"] != 1 {
t.Fatalf("the runtime then the builder: %v", tiers)
}
// Built-by orders nothing (novox/hq ADR 0360 §3): what the builder builds is beside it, after the runtime it
// stands on.
if pos["shop"] != 1 || pos["postgres"] != 1 {
t.Fatalf("what the builder builds waits for the builder: %v", tiers)
if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"]) {
t.Fatalf("what the builder builds comes after the builder: %v", tiers)
}
if pos["shop-plugin"] <= pos["shop"] {
t.Fatalf("a plugin after what it is declared on: %v", tiers)
@@ -70,23 +67,24 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
// A change to a package all three build from moves all three. The builder holds
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
// build machine that is running. The proxy is built by whichever builder runs, so it goes beside the
// controller (novox/hq ADR 0360 §3; it read "the controller, the builder, the proxy" before).
// build machine that is running. The proxy is built by the new builder: the controller, the builder,
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller
// and the proxy together" before issue 206, and the fixture had no worker-of edge.)
small := reachableFrom([]string{"mesh-controller", "builder", "route-proxy"}, edges)
smallTiers := tiersOf(small, edges)
if got := tiered(smallTiers); got != "mesh-controller,route-proxy | builder" {
t.Fatalf("the controller and the proxy, then the builder: %v", smallTiers)
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
}
// The builder alone moved: the builder, and nothing it builds.
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
t.Fatalf("a build machine change rebuilds the build machine alone: %v", only)
}
// Nothing waits on deployment for a built-by edge (novox/hq ADR 0360 §3): the builder gates no tier after it.
// Only a runtime dependency gates on deployment, and only when the module rolls out.
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"}, []string{"mesh-tools"}, edges)
p.Tier = pos["builder"]
rollsOut := func(m string) bool { return m == "builder" }
if g := gates(p, edges, rollsOut); len(g) != 0 {
if g := gates(p, edges, rollsOut); len(g) != 1 || g[0] != "builder" {
t.Fatalf("the builder gates the tier after it: %v", g)
}
p.Tier = 0
@@ -255,10 +253,10 @@ func TestAChangeToTheBuildAgentRebuildsTheBuildAgentAlone(t *testing.T) {
t.Fatalf("%v planned %v: the build agent alone", paths, p.Tiers)
}
}
// With what it builds moved beside it, both go in one tier (novox/hq ADR 0360 §3): built-by orders nothing.
// With what it builds moved beside it, the agent comes first and they follow: an order, not a widening.
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Commit: "abc"},
[]string{"build-agent", "redis"}, edges)
if len(p.Tiers) != 1 || !slices.Equal(p.Tiers[0], []string{"build-agent", "redis"}) || len(p.Modules) != 2 {
t.Fatalf("the agent and what moved beside it, in one tier: %v", p.Tiers)
if len(p.Tiers) != 2 || p.Tiers[0][0] != "build-agent" || p.Tiers[1][0] != "redis" || len(p.Modules) != 2 {
t.Fatalf("the agent, then what moved beside it: %v", p.Tiers)
}
}
+1 -8
View File
@@ -111,13 +111,8 @@ func aBacklog(t *testing.T) *backlogMesh {
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
return nil, err
}
// A broken machine reports its apply failed (novox/hq ADR 0360 §7: the report is the verdict).
outcome := inventory.OutcomeApplied
if b.broken[node] {
outcome = inventory.OutcomeFailed
}
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
Outcome: outcome, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
Outcome: inventory.OutcomeApplied, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
return nil, err
}
}
@@ -169,7 +164,6 @@ func (b *backlogMesh) release(t *testing.T) inventory.Plan {
// The backlog goes out one machine at a time: the first judged before the second is sent, each build's
// pass kept; a rebuild with the same bytes is no move at all; and a send outside a gate is refused.
func TestTheBacklogIsReleasedOneMachineAtATimeEachJudged(t *testing.T) {
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
b := aBacklog(t)
ctx := t.Context()
@@ -225,7 +219,6 @@ func TestTheBacklogIsReleasedOneMachineAtATimeEachJudged(t *testing.T) {
// A release that fails on its first machine puts back what it carried there, goes no further, and the
// next one waits for a person, said as a condition; a person releases it with why.
func TestAFailedReleaseIsPutBackAndTheNextWaitsForAPerson(t *testing.T) {
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
b := aBacklog(t)
ctx := t.Context()
inv := b.open.inventory
-1
View File
@@ -31,7 +31,6 @@ import (
// nothing is put back, the walk goes on to the workstation, and openrazer's condition says, in one sentence
// for the operator, that a new login is needed on the laptop.
func TestReplay318(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
-2
View File
@@ -16,7 +16,6 @@ import (
// at its gate with the pass's own words, and put it back. Written with only what the controller had before
// its fix, so it is laid over the commit before.
func TestReplay335(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
t.Run("the first machine's later reports", testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports)
t.Run("a walk stopped after the pass", testAWalkStoppedAfterItsGatePassedKeepsThePass)
}
@@ -78,7 +77,6 @@ func testAWalkStoppedAfterItsGatePassedKeepsThePass(t *testing.T) {
// the first send is older than the wait for a first machine's report. Read before the pass is taken over, the
// carried module's step said the first machine never applied it, and the passed build was put back.
func TestACarriedModuleTakesItsLeadsPassBeforeItsStepIsRead(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
tm := aTierMesh(t, "m01", "m02")
ctx := t.Context()
-1
View File
@@ -19,7 +19,6 @@ import (
// had before its fix — the stores, register, assign, a plan saved and advanced, the watchdogs' gathering and
// seeing — so it is laid over the commit before.
func TestReplay336(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
-1
View File
@@ -139,7 +139,6 @@ func TestReplay273AConsumerBesideItsStoreStaysBoundToIt(t *testing.T) {
// one, kept below. Heard as bytes, so an older controller reads them as it reads any report — this file is
// written only with what the controller had before the judging, for the prover to lay over that commit.
func TestReplayCrashLoopFailsItsGateOnTheFirstMachine(t *testing.T) {
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
+1 -11
View File
@@ -11,7 +11,7 @@ import (
"github.com/novox/mesh-controller/internal/inventory"
)
// novox/hq ADR 0353, kept behind gateFirstMachine for walks a controller before ADR 0360 started: after its first machine passes, a build goes to every other machine at once, each judged by
// novox/hq ADR 0353: after its first machine passes, a build goes to every other machine at once, each judged by
// its own gate and a failing one put back by itself; and a gate's readings outlive the controller that took them —
// a machine already sent what a send is for is not sent again, and its judging goes on from what was read.
//
@@ -62,7 +62,6 @@ func events(t *testing.T) *[]string {
// Once the first machine passed, the rest are both sent before either is judged (ADR 0353 §1); before, the second
// was judged to its verdict before the third was sent.
func TestAReleaseSendsTheRestAtOnceAfterTheFirstPasses(t *testing.T) {
withTheGate(t)
judgeLeavesAsTheCore(t)
b := aBacklogOnThree(t)
ctx := t.Context()
@@ -90,7 +89,6 @@ func TestAReleaseSendsTheRestAtOnceAfterTheFirstPasses(t *testing.T) {
// A machine of the rest whose gate fails is put back at once, and the other machine of the step goes on to its own
// verdict (ADR 0353 §3); before, the walk stopped at the failure and the machine after it was never sent.
func TestARestMachineThatFailsIsPutBackAloneAndTheOthersAreJudgedOn(t *testing.T) {
withTheGate(t)
judgeLeavesAsTheCore(t)
b := aBacklogOnThree(t)
ctx := t.Context()
@@ -127,7 +125,6 @@ func TestARestMachineThatFailsIsPutBackAloneAndTheOthersAreJudgedOn(t *testing.T
// A walk whose first machine already runs its build under another walk's gate sends nothing and goes on from that
// gate's readings (ADR 0353 §7); before, it sent the machine again and judged from its first reading.
func TestAWalkTakesOverTheReadingsOfAGateJudgingTheSameBuildThere(t *testing.T) {
withTheGate(t)
judgeLeavesAsTheCore(t)
b := aBacklog(t)
ctx := t.Context()
@@ -166,7 +163,6 @@ func TestAWalkTakesOverTheReadingsOfAGateJudgingTheSameBuildThere(t *testing.T)
// A walk's first send that was made and not kept — the controller replaced between the two — is not made again
// by the controller that takes over: the gate counts from that send (ADR 0353 §5, §6).
func TestAFirstSendMadeAndNotKeptIsNotSentAgain(t *testing.T) {
withTheGate(t)
judgeLeavesAsTheCore(t)
g := aGateMesh(t)
ctx := t.Context()
@@ -204,7 +200,6 @@ func TestAFirstSendMadeAndNotKeptIsNotSentAgain(t *testing.T) {
// A release's send kept before it was made, and made, is judged by the controller that takes over without being
// made again (ADR 0353 §8).
func TestAReleaseSendKeptAndMadeIsNotMadeAgain(t *testing.T) {
withTheGate(t)
judgeLeavesAsTheCore(t)
b := aBacklog(t)
ctx := t.Context()
@@ -236,7 +231,6 @@ func TestAReleaseSendKeptAndMadeIsNotMadeAgain(t *testing.T) {
// A build no gate has passed goes to one machine of a step, its first; a machine that would be its second waits
// for the next step, and one whose every build passed goes now (ADR 0353 §2).
func TestABuildThatPassedNoGateGoesToOneMachineOfAStep(t *testing.T) {
withTheGate(t)
waiting := map[string][]inventory.CarriedMove{
"desk": {{Module: "app", To: "c2"}, {Module: "new", To: "n1"}},
"laptop": {{Module: "app", To: "c2"}, {Module: "new", To: "n1"}},
@@ -257,7 +251,6 @@ func TestABuildThatPassedNoGateGoesToOneMachineOfAStep(t *testing.T) {
// The node-engine and the mesh's resolver go to the rest one machine at a time, even passed (ADR 0353 §9): a step
// carries either to one machine only, while what else passed goes everywhere at once.
func TestTheNodeEngineAndTheResolverGoToTheRestOneAtATime(t *testing.T) {
withTheGate(t)
waiting := map[string][]inventory.CarriedMove{
"desk": {{Module: hostModule, To: "h2"}},
"laptop": {{Module: hostModule, To: "h2"}, {Module: "app", To: "c2"}},
@@ -284,7 +277,6 @@ func TestTheNodeEngineAndTheResolverGoToTheRestOneAtATime(t *testing.T) {
// A first machine that fails still stops the walk (ADR 0236 §5, kept by ADR 0353 §3): the rest are never sent.
func TestAFirstMachineThatFailsStillStopsTheWalk(t *testing.T) {
withTheGate(t)
b := aBacklogOnThree(t)
ctx := t.Context()
gateEvery = 0
@@ -306,7 +298,6 @@ func TestAFirstMachineThatFailsStillStopsTheWalk(t *testing.T) {
// The control node is sent last in a step of the rest (ADR 0353 §1): the machine holding the controller is
// ordered after every other.
func TestTheControlNodeIsSentLastInAStep(t *testing.T) {
withTheGate(t)
judgeLeavesAsTheCore(t)
b := aBacklogOnThree(t)
ctx := t.Context()
@@ -331,7 +322,6 @@ func TestTheControlNodeIsSentLastInAStep(t *testing.T) {
// A machine of the rest that says nothing on its send is left silent after fifteen minutes: not failed, not put
// back; a rest that went silent whole ends failed, said as a condition (ADR 0353 §4).
func TestARestMachineSilentPastItsBoundIsLeftAndSaid(t *testing.T) {
withTheGate(t)
judgeLeavesAsTheCore(t)
b := aBacklog(t)
ctx := t.Context()
-176
View File
@@ -1,176 +0,0 @@
package main
import (
"context"
"fmt"
"slices"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// **A module moves only when its program changes** (novox/hq issue 721). A merge check builds every module the
// delivery plan moves and compares each with the build the mesh holds of it; a walk is cut without a module whose
// check found its whole image the same as that build, while the mesh still holds it, and says so among the modules
// it left unchanged. A merge of one sentence in a package a program imports but never reaches moves no build of it.
// sameAsRunning are the modules of a walk its checks found the same as the build the mesh holds, each with what its
// check said. **Only when every pull request the walk answers was checked at the head it merged, and every one of
// those checks built the module and found it the same as the build the mesh still holds**: a merge the walk answers
// without a pull request, a check of another head, a check that built it differently, or a held build that moved
// since, leaves the module in the walk.
func sameAsRunning(plan *inventory.Plan, builds []link.CheckedBuild, held map[string]string) map[string]string {
if plan.Delivery == nil || len(plan.Delivery.Merges) == 0 {
return nil
}
for _, m := range plan.Delivery.Merges {
if _, ok := pullOf(m); !ok {
return nil
}
}
pulls := reuseAskOf(plan).Pulls
heads := mergedHeadsOf(plan)
out := map[string]string{}
for name := range plan.Modules {
said := ""
for _, pull := range pulls {
b, ok := checkedBuildOf(builds, pull, heads[pull], name)
if !ok || !stillHeld(b.Built, name, held) {
said = ""
break
}
if said == "" {
said = b.Built.Same
}
}
if said != "" {
out[name] = said
}
}
if len(out) == 0 {
return nil
}
return out
}
// checkedBuildOf is the newest build a check of the pull request at head made of the module; false when none did.
func checkedBuildOf(builds []link.CheckedBuild, pull link.PullRef, head, module string) (link.CheckedBuild, bool) {
if head == "" {
return link.CheckedBuild{}, false
}
for _, b := range builds {
if b.Pull == pull && b.Built.Module == module && link.SameHead(b.Head, head) {
return b, true
}
}
return link.CheckedBuild{}, false
}
// stillHeld is whether a check found every artifact of the module the same as a build the mesh still holds.
func stillHeld(b link.CheckBuilt, module string, held map[string]string) bool {
if b.Same == "" || b.NotBuilt != "" || len(b.Made) == 0 || len(b.SameAs) != len(b.Made) {
return false
}
for _, m := range b.Made {
was := pinnedDigest(b.SameAs[m.Name])
if was == "" || was != pinnedDigest(held[module+"/"+m.Name]) {
return false
}
}
return true
}
// pinnedDigest is a reference's pinned digest, empty when it is not pinned.
func pinnedDigest(ref string) string {
if i := strings.LastIndex(ref, "@"); i >= 0 {
return ref[i+1:]
}
return ""
}
// leaveSameAsRunning takes from a walk being cut every module its checks found the same as the build the mesh holds
// (sameAsRunning), and every dependent in the walk only for one of those (withDependentsOf): what was taken, with why. **Anything not read leaves the walk as it was**, said.
func leaveSameAsRunning(ctx context.Context, inv *inventory.Inventory, plan *inventory.Plan, moved []string,
edges []inventory.Edge) map[string]string {
if plan.Delivery == nil || len(plan.Modules) == 0 {
return nil
}
pulls := reuseAskOf(plan).Pulls
if len(pulls) == 0 {
return nil
}
builds, err := checkedBuildsOf(ctx, pulls)
if err != nil {
fmt.Printf(" the checks' builds could not be read, so every module moves: %v\n", err)
// empty-on-error: said above; nothing left out of the walk is the safe answer, every module moves as before
return nil
}
held, err := inv.RegisteredHeld(ctx)
if err != nil {
fmt.Printf(" what the mesh holds could not be read, so every module moves: %v\n", err)
// empty-on-error: said above; nothing left out of the walk is the safe answer, every module moves as before
return nil
}
same := withDependentsOf(sameAsRunning(plan, builds, held), plan, moved, edges)
if len(same) == 0 {
return nil
}
names := make([]string, 0, len(same))
for n := range same {
names = append(names, n)
}
sort.Strings(names)
withhold(plan, names, nil)
for _, n := range names {
fmt.Printf(" %s left out: %s\n", n, same[n])
}
return same
}
// withDependentsOf adds to the modules found the same every module of the walk that is in it only as a dependent of
// one of them (novox/hq ADR 0267 rule 3): **a dependent is left out when its base comes out the same**, since the
// base it stands on stays the build the mesh holds. A module a merge moves itself, or a dependent of a base that
// still moves, stays. moved are the modules the walk's merges move; edges the graph the walk was planned along.
func withDependentsOf(same map[string]string, plan *inventory.Plan, moved []string, edges []inventory.Edge) map[string]string {
if len(same) == 0 {
return same
}
var still []string
for _, n := range moved {
if same[n] == "" {
still = append(still, n)
}
}
keep := map[string]bool{}
for _, n := range reachableFrom(still, edges) {
keep[n] = true
}
// Until nothing more is found: a dependent of a dependent left out is left out too.
for grew := true; grew; {
grew = false
for n := range plan.Modules {
if keep[n] || same[n] != "" {
continue
}
var bases []string
for _, e := range edges {
ordering := e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf ||
e.Kind == inventory.EdgePackages || e.Kind == edgeGroupOrder
if !ordering && e.From == n && same[e.To] != "" && !slices.Contains(bases, e.To) {
bases = append(bases, e.To)
}
}
sort.Strings(bases)
if len(bases) == 0 {
// In the walk for no base found the same: whatever brought it in is not said here, so it moves.
continue
}
same[n] = "unchanged: it is in the walk only for " + strings.Join(bases, ", ") +
", which came out the same as the build the mesh holds (ADR 0267 rule 3)"
grew = true
}
}
return same
}
-141
View File
@@ -1,141 +0,0 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// TestAWalkLeavesOutAModuleWhoseProgramDidNotChange is novox/hq issue 721: mesh-controller #335 changed one sentence
// that mesh-builder never reaches; its check found build-agent the same as the build the mesh holds, so the walk moves
// mesh-controller alone and says build-agent is unchanged.
func TestAWalkLeavesOutAModuleWhoseProgramDidNotChange(t *testing.T) {
pull := link.PullRef{Owner: "novox", Repo: "mesh-controller", Number: 335}
head := "34050dc6aaaabbbbccccdddd"
aPlan := func() *inventory.Plan {
return &inventory.Plan{Tiers: [][]string{{"mesh-controller", "build-agent"}},
Modules: map[string]*inventory.PlanModule{"mesh-controller": {}, "build-agent": {}},
Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{{Repository: "novox/mesh-controller",
Commit: "c2ec0a60", Number: 335, Head: head}}}}
}
held := map[string]string{"build-agent/agent": "store/build-agent@sha256:aaaa",
"mesh-controller/controller": "store/mesh-controller@sha256:cccc"}
builds := []link.CheckedBuild{
{Pull: pull, Head: head[:12], Built: link.CheckBuilt{Module: "build-agent", Key: "k1",
Made: []link.MadeArtifact{{Name: "agent", Kind: "image", Reference: "scratch/build-agent@sha256:bbbb"}},
Same: "unchanged: its program is the same as running build agent aaaa",
SameAs: map[string]string{"agent": "10.0.0.1:5000/build-agent@sha256:aaaa"}}},
{Pull: pull, Head: head[:12], Built: link.CheckBuilt{Module: "mesh-controller", Key: "k2",
Made: []link.MadeArtifact{{Name: "controller", Kind: "image", Reference: "scratch/mesh-controller@sha256:dddd"}}}},
}
p := aPlan()
same := sameAsRunning(p, builds, held)
if len(same) != 1 || same["build-agent"] == "" {
t.Fatalf("the walk leaves out %v, want build-agent alone", same)
}
withhold(p, []string{"build-agent"}, nil)
if len(p.Tiers) != 1 || len(p.Tiers[0]) != 1 || p.Tiers[0][0] != "mesh-controller" {
t.Fatalf("the walk moves %v, want mesh-controller alone", p.Tiers)
}
// **The held build moved since the check**: what it was compared with no longer runs.
moved := map[string]string{"build-agent/agent": "store/build-agent@sha256:eeee"}
if same := sameAsRunning(aPlan(), builds, moved); len(same) != 0 {
t.Fatalf("a module compared with a build the mesh no longer holds is left out: %v", same)
}
// **A check of another head** says nothing of this merge.
other := aPlan()
other.Delivery.Merges[0].Head = "ffffffffffffffff"
if same := sameAsRunning(other, builds, held); len(same) != 0 {
t.Fatalf("a check of another head left out %v", same)
}
// **Another pull request the walk answers** that did not find it the same leaves it in.
two := aPlan()
two.Delivery.Merges = append(two.Delivery.Merges, inventory.PlanMerge{Repository: "novox/mesh-catalog",
Commit: "1234", Number: 9, Head: "abcdefabcdefabcdef"})
if same := sameAsRunning(two, builds, held); len(same) != 0 {
t.Fatalf("a module another merge moves is left out: %v", same)
}
// **A merge with no pull request** leaves the walk as it is.
direct := aPlan()
direct.Delivery.Merges[0].Number = 0
if same := sameAsRunning(direct, builds, held); len(same) != 0 {
t.Fatalf("a walk answering a merge without a pull request left out %v", same)
}
}
// TestADependentOfABaseThatCameOutTheSameIsLeftOut is ADR 0267 rule 3 (novox/hq issue 607): a dependent is in a walk
// for its base; when the base comes out the same as the build the mesh holds, the dependent stands on that build
// still, and leaves the walk with it — and so does a dependent of that dependent. One a merge moves itself stays.
func TestADependentOfABaseThatCameOutTheSameIsLeftOut(t *testing.T) {
edges := []inventory.Edge{{From: "app", To: "base", Kind: inventory.EdgeDeclared},
{From: "app2", To: "app", Kind: inventory.EdgeDeclared}, {From: "own", To: "base", Kind: inventory.EdgeDeclared}}
plan := &inventory.Plan{Modules: map[string]*inventory.PlanModule{"base": {}, "app": {}, "app2": {}, "own": {}}}
same := withDependentsOf(map[string]string{"base": "unchanged: its program is the same"}, plan,
[]string{"base", "own"}, edges)
if !strings.Contains(same["app"], "only for base") || !strings.Contains(same["app2"], "only for app") {
t.Fatalf("the dependents of a base that came out the same are not left out: %v", same)
}
if _, left := same["own"]; left {
t.Fatalf("a module its merge moves itself is left out for its base: %v", same)
}
// A dependent of a base that still moves stays.
edges = append(edges, inventory.Edge{From: "app", To: "own", Kind: inventory.EdgeDeclared})
same = withDependentsOf(map[string]string{"base": "unchanged"}, plan, []string{"base", "own"}, edges)
if _, left := same["app"]; left {
t.Fatalf("a dependent of a base that still moves is left out: %v", same)
}
}
// Against the store, the path a real merge takes (novox/hq issues 721 and 611): a merge whose check built its one
// module the same as the build the mesh holds is cut into a walk done with nothing to walk, whose record names the
// module unchanged and why — the walk mesh-delivery reads as delivered, never stranded.
func TestAWalkWhoseEveryBuildCameOutTheSameIsDone(t *testing.T) {
open := windowed(t)
endsItsLine(t)
asksRecorded(t)
ctx := t.Context()
inv := open.inventory
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-one", Repository: "novox/one", Ref: "main",
Module: "one", Commit: "c0", On: "builder", Asked: t0.Add(-time.Hour),
Made: []inventory.Artifact{{Name: "one", Kind: "image", Reference: "store/one@sha256:aaaa"}},
Sources: []inventory.BuildSource{{Paths: []string{"main.go", "go.mod"}}}}); err != nil {
t.Fatal(err)
}
// What runs: the module registered with that build's image (RegisteredHeld reads the module table).
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "one", Version: "1", Resources: []map[string]any{
{"id": "app", "type": "container", "name": "one", "image": "store/one@sha256:aaaa"}}},
inventory.Source{Repository: "novox/one", Ref: "main", BuiltFrom: "c0", Asked: time.Now()}); err != nil {
t.Fatal(err)
}
pull := link.PullRef{Owner: "novox", Repo: "one", Number: 7}
was := checkedBuildsOf
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
return []link.CheckedBuild{{Pull: pull, Head: "headaaaaaaaa", Built: link.CheckBuilt{Module: "one", Key: "k",
Made: []link.MadeArtifact{{Name: "one", Kind: "image", Reference: "scratch/one@sha256:bbbb"}},
Same: "unchanged: its program is the same as running build one aaaa",
SameAs: map[string]string{"one": "10.0.0.1:5000/one@sha256:aaaa"}}}}, nil
}
t.Cleanup(func() { checkedBuildsOf = was })
code := repoMerge("one", "one1aaaaaaaa", t0)
code.Number, code.HeadSHA = 7, "headaaaaaaaa1111"
hear(t, open, code, t0.Add(time.Second))
cutAt(t, open, t0.Add(2*time.Minute))
ws, _ := walks(t, open)
if len(ws) != 1 {
t.Fatalf("walks: %+v", ws)
}
w := ws[0]
if w.State != inventory.PlanDone || len(w.Tiers) != 0 || len(w.Modules) != 0 {
t.Fatalf("the walk reads %s, tiers %v, modules %v; want done with nothing to walk", w.State, w.Tiers, w.Modules)
}
if w.Delivery == nil || !strings.Contains(w.Delivery.Unchanged["one"], "its program is the same as running build") {
t.Fatalf("the walk does not say one matched what runs: %+v", w.Delivery)
}
}
-3
View File
@@ -43,9 +43,6 @@ func TestMain(m *testing.M) {
}
if os.Getenv(echoEnvironment) != "" {
fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal())
if os.Getenv(echoFails) != "" {
os.Exit(3)
}
os.Exit(0)
}
conditions.Unsayable = func(o conditions.Observation, field string, r outward.Refusal) {
+35 -1
View File
@@ -589,9 +589,43 @@ func watchAdvisories(f *signalFacts) []conditions.Observation {
if f.busFaultsErr != nil {
out = append(out, busFaultsUnread(f.busFaultsErr))
}
if o, ok := busWatchDead(f); ok {
out = append(out, o)
}
return append(out, watchDeadLetters(f)...)
}
// kindBusWatchDead is the bus module's watch not heard, or saying it cannot see (novox/hq ADR 0357, review
// 274): while it lasts, a slow consumer would read as none.
const kindBusWatchDead = "bus-watch-dead"
// busWatchStale is how long the watch may be silent: three of its looks, and one more for the tick.
const busWatchStale = 2 * time.Minute
// busWatchDead says the watch silent past busWatchStale, or saying it is not healthy. Nothing while the stream
// holds no heartbeat at all: a bus module older than its watch says nothing either way.
func busWatchDead(f *signalFacts) (conditions.Observation, bool) {
if !f.busWatchHeard {
return conditions.Observation{}, false
}
b := f.busWatch
o := conditions.Observation{Scope: conditions.ScopeBus, ID: "bus-module", Kind: kindBusWatchDead,
Machine: b.Machine, Severity: conditions.Warning}
switch {
case f.now.Sub(b.At) > busWatchStale:
o.Summary = fmt.Sprintf("the bus module's watch of slow consumers has said nothing for %s, so a client "+
"the bus cut off would not be said", ago(f.now.Sub(b.At)))
o.Said = "last heartbeat " + b.At.UTC().Format(time.RFC3339)
return o, true
case !b.Healthy:
o.Summary = "the bus module's watch of slow consumers says it cannot see, so a client the bus cut off " +
"would not be said"
o.Said = firstLine(b.Why)
return o, true
}
return conditions.Observation{}, false
}
// kindBusFaultsUnread is the bus module's events not read back (novox/hq ADR 0357): every other user's
// slow consumer is unheard while it lasts, and the controller's own signals are still said.
const kindBusFaultsUnread = "bus-faults-unread"
@@ -862,7 +896,7 @@ func watchLease(f *signalFacts) []conditions.Observation {
}
}
if len(lost) > 0 {
how := "lost it: another holder wrote its key, or no renewal was made within the key's age"
how := "lost it: its renewal was refused or could not be made"
if newest.How == inventory.EpochExpired {
how = "stopped renewing it without giving it back, and was taken over"
}
-157
View File
@@ -1,157 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/link"
)
// A new push to a pull request ends the check of its old head (novox/hq issue 726).
//
// A check judges one head; once the pull request has another, its verdict answers nobody and its run holds a build
// machine for minutes the new head waits behind. So when a check is asked for pull request N of a repository at a
// head, **every check of that pull request at another head ends**: a waiting one is dropped from the queue as
// `cancel` drops it, one in flight is ended on its machine as `kill` ends it. Each is said in the log.
//
// **Never anything else**: a try (`try`, asked for no pull request), a walk's build (no check), a delivery group's
// composed check (it answers several pull requests), another pull request's check, a dead ask (left for `clear
// --dead`), and a check of the same head — a recheck of that head is not another head.
// **Only for the pull request's current head.** Announcements of a pull request are not ordered, one is heard again
// after a restart, and a recheck asks for the head it recorded: a check asked for a head the pull request no longer
// has must never end the check of the head it has. So the forge is asked first which head the pull request has; only
// when it names this one does anything end, and when it cannot be asked, or names another, nothing ends and that is
// said.
// forgePullHead asks the forge, through its module's tools on the bus, which commit a pull request's head is. A
// variable so a test needs no forge.
var forgePullHead = func(ctx context.Context, js *broker.JetStream, owner, repo string, number int) (string, error) {
raw, _ := json.Marshal(map[string]any{"path": fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, number)})
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second)
if err != nil {
return "", err
}
if answer.Error != "" {
return "", errors.New(answer.Error)
}
var pull struct {
Result struct {
Head struct {
SHA string `json:"sha"`
} `json:"head"`
} `json:"result"`
}
if err := json.Unmarshal(answer.Result, &pull); err != nil {
return "", fmt.Errorf("the forge's answer about the pull request cannot be read: %w", err)
}
if pull.Result.Head.SHA == "" {
return "", errors.New("the forge named no head for the pull request")
}
return pull.Result.Head.SHA, nil
}
// supersededChecks are the checks in the queue that a check of owner/repo#number at head ends: of the same pull
// request, at another head, waiting or in flight, and not the ask keep.
func supersededChecks(q link.Queue, owner, repo string, number int, head, keep string) []link.QueuedAsk {
if number <= 0 || head == "" {
return nil
}
var out []link.QueuedAsk
for _, a := range q.Asks {
c := a.Request.Check
switch {
case c == nil, link.IsTry(a.Request), c.Group != "", c.Number != number, a.ID == keep,
!strings.EqualFold(c.Owner, owner), !strings.EqualFold(c.Repo, repo),
a.State != link.AskWaiting && a.State != link.AskInFlight:
continue
}
ref := a.Request.Ref
if ref == "" {
ref = a.Ref
}
if ref == "" || ref == head || link.SameHead(ref, head) {
continue
}
out = append(out, a)
}
return out
}
// endSupersededChecks ends the checks a check of p at its head supersedes, keep being the new check's own ask, and
// says each in the log. A waiting one is cancelled — nothing recorded: a check builds nothing, and the forge's status
// of the old head is no longer the pull request's — and one a machine builds is killed there.
func endSupersededChecks(ctx context.Context, js *broker.JetStream, seat string, p link.PullUpdated, keep string) []string {
if p.Number <= 0 || p.Commit == "" {
return nil
}
current, err := forgePullHead(ctx, js, p.Owner, p.Repo, p.Number)
if err != nil {
return []string{fmt.Sprintf("%s/%s#%d (%.8s): nothing ended — the forge could not say the pull request's "+
"current head, so checks of its other heads keep running: %v", p.Owner, p.Repo, p.Number, p.Commit, err)}
}
if current != p.Commit && !link.SameHead(current, p.Commit) {
return []string{fmt.Sprintf("%s/%s#%d (%.8s): nothing ended — the pull request's head is %.12s now, not "+
"this one", p.Owner, p.Repo, p.Number, p.Commit, current)}
}
q, err := link.ReadQueue(ctx, js, seat)
if err != nil {
return []string{fmt.Sprintf("%s/%s#%d (%.8s): could not read the %s queue for checks of its earlier heads: %v",
p.Owner, p.Repo, p.Number, p.Commit, seat, err)}
}
var said []string
for _, a := range supersededChecks(q, p.Owner, p.Repo, p.Number, p.Commit, keep) {
old := a.Request.Ref
if old == "" {
old = a.Ref
}
what := fmt.Sprintf("check of %s/%s#%d at %.12s", p.Owner, p.Repo, p.Number, old)
var err error
if a.State == link.AskInFlight && a.On != "" {
var answer link.Answer
answer, err = link.AskSeatTool(ctx, js.Conn(), seat, "kill", a.On, map[string]string{"id": a.ID}, killAnswer)
if err == nil && answer.Error != "" {
err = fmt.Errorf("%s refused: %s", a.On, answer.Error)
}
} else {
_, err = cancelAsk(ctx, js, nil, seat, a)
}
if err != nil {
said = append(said, fmt.Sprintf("not ended: %s (%s) — superseded by %.12s, but: %v", what, a.ID, p.Commit, err))
continue
}
said = append(said, fmt.Sprintf("ended: %s — superseded by %.12s (%s, %s)", what, p.Commit, a.ID, a.State))
}
return said
}
// endingSupersededChecks ends them beside the announcement, so a kill waiting on its machine never holds the
// handler: on its own connection to the bus, within twice a kill's wait. A one-shot process (oneShot) ends them
// within the call instead, as its exit would cut them off.
func endingSupersededChecks(ctx context.Context, seat string, p link.PullUpdated, keep string, oneShot bool) {
end := func() {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*killAnswer)
defer cancel()
js, err := dialTheBus()
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s#%d (%.8s): could not reach the bus to end checks of its earlier heads: %v\n",
p.Owner, p.Repo, p.Number, p.Commit, err)
return
}
defer js.Close()
for _, line := range endSupersededChecks(ctx, js, seat, p, keep) {
fmt.Println(line)
}
}
if oneShot {
end()
return
}
go end()
}
-192
View File
@@ -1,192 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/link"
)
// A new push to a pull request ends the check of its old head (novox/hq issue 726): the waiting one is dropped from
// the queue, the one in flight is killed on its machine, and each is said. A try, a walk's build, another pull
// request's check, a group's check and a recheck of the new head are left alone. On the bus alone: no store.
func TestANewHeadEndsTheChecksOfTheOldOnes(t *testing.T) {
js, _ := aBuildQueue(t)
ctx := t.Context()
const (
oldHead = "1111111111111111111111111111111111111111"
olderHead = "2222222222222222222222222222222222222222"
newHead = "3333333333333333333333333333333333333333"
)
publish := func(r link.BuildRequest) {
t.Helper()
body, _ := json.Marshal(r)
if _, err := js.Context().Publish(link.BuildWork(), body); err != nil {
t.Fatal(err)
}
}
check := func(id string, number int, head string) link.BuildRequest {
return link.BuildRequest{ID: id, Repository: "https://forge.example/novox/a.git", Ref: head,
Check: &link.CheckRequest{Owner: "novox", Repo: "a", Number: number}}
}
// In flight first: taken by a holder, started on ace.
publish(check("check-running", 7, olderHead))
worker, _ := broker.HolderConsumerFor("", "", broker.DeclaredSeat{Name: link.TheBuildMachine, Accepts: []string{"build"}})
sub, err := js.Context().PullSubscribe(worker.Filters[0], worker.Name, nats.Bind(worker.Stream, worker.Name), nats.ManualAck())
if err != nil {
t.Fatal(err)
}
defer func() { _ = sub.Unsubscribe() }()
if _, err := sub.Fetch(1, nats.MaxWait(3*time.Second)); err != nil {
t.Fatal(err)
}
started, _ := json.Marshal(link.BuildStart{ID: "check-running", On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)})
if _, err := js.Context().Publish(link.BuildStarted(), started); err != nil {
t.Fatal(err)
}
publish(check("check-old", 7, oldHead))
publish(check("check-other-pull", 8, oldHead))
publish(check("check-recheck", 7, newHead))
publish(check("check-new", 7, newHead))
try := check("try-old", 7, oldHead)
try.Check.Number, try.Check.Try = 0, true
publish(try)
group := check("group-old", 7, oldHead)
group.Check.Group = "g1"
publish(group)
publish(link.BuildRequest{ID: "walk-build", Repository: "https://forge.example/novox/a.git", Ref: oldHead})
var mu sync.Mutex
var killed []string
stop, err := link.OverNATS{Conn: js.Conn()}.ServeNodeSeatTools(link.TheBuildMachine, "ace", map[string]link.ToolHandler{
"kill": func(_ context.Context, raw json.RawMessage) (any, error) {
var args struct{ ID string }
_ = json.Unmarshal(raw, &args)
mu.Lock()
killed = append(killed, args.ID)
mu.Unlock()
return map[string]any{"said": "killed " + args.ID}, nil
}}, nil)
if err != nil {
t.Fatal(err)
}
defer stop()
forgeSays(t, newHead, nil)
said := endSupersededChecks(ctx, js, link.TheBuildMachine,
link.PullUpdated{Owner: "novox", Repo: "a", Number: 7, Commit: newHead}, "check-new")
mu.Lock()
if len(killed) != 1 || killed[0] != "check-running" {
t.Errorf("the holder was asked to kill %v, want only the in-flight check of the older head", killed)
}
mu.Unlock()
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
if err != nil {
t.Fatal(err)
}
if _, found := q.Find("check-old"); found {
t.Error("the waiting check of the old head is still in the queue")
}
for _, id := range []string{"check-other-pull", "check-recheck", "check-new", "try-old", "group-old", "walk-build"} {
if _, found := q.Find(id); !found {
t.Errorf("%s was ended; only checks of the same pull request at another head end", id)
}
}
text := strings.Join(said, "\n")
for _, want := range []string{
"ended: check of novox/a#7 at " + oldHead[:12] + " — superseded by " + newHead[:12],
"ended: check of novox/a#7 at " + olderHead[:12] + " — superseded by " + newHead[:12],
} {
if !strings.Contains(text, want) {
t.Errorf("the log does not say %q:\n%s", want, text)
}
}
if len(said) != 2 {
t.Errorf("said %d lines, want 2:\n%s", len(said), text)
}
}
// Which asks a check supersedes, read from the queue alone.
func TestSupersededChecksAreOnlyThePullRequestsOtherHeads(t *testing.T) {
ask := func(id, state, ref string, c *link.CheckRequest) link.QueuedAsk {
return link.QueuedAsk{ID: id, State: state, Ref: ref, Request: link.BuildRequest{ID: id, Ref: ref, Check: c}}
}
pr := func(n int) *link.CheckRequest { return &link.CheckRequest{Owner: "Novox", Repo: "A", Number: n} }
head := "abcdefabcdefabcdef0123456789abcdef012345"
q := link.Queue{Asks: []link.QueuedAsk{
ask("old", link.AskWaiting, "0123456789ab0123456789ab0123456789ab0123", pr(7)),
ask("short-same", link.AskWaiting, head[:12], pr(7)),
ask("dead", link.AskDead, "0123456789ab0123456789ab0123456789ab0123", pr(7)),
ask("other", link.AskWaiting, "0123456789ab0123456789ab0123456789ab0123", pr(8)),
ask("build", link.AskWaiting, "0123456789ab0123456789ab0123456789ab0123", nil),
ask("keep", link.AskWaiting, "0123456789ab0123456789ab0123456789ab0123", pr(7)),
}}
got := supersededChecks(q, "novox", "a", 7, head, "keep")
if len(got) != 1 || got[0].ID != "old" {
t.Fatalf("superseded %+v, want only old", got)
}
if got := supersededChecks(q, "novox", "a", 0, head, ""); got != nil {
t.Errorf("a check of no pull request superseded %+v", got)
}
}
// forgeSays makes the forge name head as every pull request's current head, or fail with err.
func forgeSays(t *testing.T, head string, err error) {
t.Helper()
was := forgePullHead
forgePullHead = func(context.Context, *broker.JetStream, string, string, int) (string, error) { return head, err }
t.Cleanup(func() { forgePullHead = was })
}
// An older head heard after a newer one — an announcement out of order, one heard again after a restart, a recheck
// of a recorded head — ends nothing: the forge says the pull request's head is the newer one. Nor does a check whose
// pull request's head the forge cannot say.
func TestAnOlderHeadHeardLateEndsNothing(t *testing.T) {
js, _ := aBuildQueue(t)
ctx := t.Context()
const (
oldHead = "1111111111111111111111111111111111111111"
newHead = "3333333333333333333333333333333333333333"
)
for _, id := range []string{"check-new", "check-old-late"} {
head := newHead
if id == "check-old-late" {
head = oldHead
}
body, _ := json.Marshal(link.BuildRequest{ID: id, Repository: "https://forge.example/novox/a.git", Ref: head,
Check: &link.CheckRequest{Owner: "novox", Repo: "a", Number: 7}})
if _, err := js.Context().Publish(link.BuildWork(), body); err != nil {
t.Fatal(err)
}
}
late := link.PullUpdated{Owner: "novox", Repo: "a", Number: 7, Commit: oldHead}
forgeSays(t, newHead, nil)
said := endSupersededChecks(ctx, js, link.TheBuildMachine, late, "check-old-late")
if len(said) != 1 || !strings.Contains(said[0], "nothing ended") {
t.Errorf("an older head heard late said %q", said)
}
forgeSays(t, "", errors.New("nothing serves gitea.gitea_api"))
said = endSupersededChecks(ctx, js, link.TheBuildMachine,
link.PullUpdated{Owner: "novox", Repo: "a", Number: 7, Commit: newHead}, "check-new")
if len(said) != 1 || !strings.Contains(said[0], "nothing ended") {
t.Errorf("a head the forge could not confirm said %q", said)
}
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"check-new", "check-old-late"} {
if _, found := q.Find(id); !found {
t.Errorf("%s was ended", id)
}
}
}
-7
View File
@@ -171,7 +171,6 @@ func (tm *tierMesh) raise(machine, kind, summary string) {
// The incident's tier, small: a dozen modules whose first machine is the same are sent there in one
// send, judged by one gate, and the rest of their machines are sent once — two sends, not two dozen.
func TestATierOfManyModulesIsSentToEachMachineOnce(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
var modules []string
for i := 1; i <= 12; i++ {
@@ -224,7 +223,6 @@ func TestATierOfManyModulesIsSentToEachMachineOnce(t *testing.T) {
// settling, is the node tools' — inside their settle window, the gate's bound — and no failure of the
// modules sent with them: at the bound only the node tools are put back.
func TestACoreBehindWhileTheNodeToolsSettleFailsNoOtherModule(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
tm := aTierMesh(t, "app1", "app2", broker.RuntimeModule)
ctx := t.Context()
inv := tm.open.inventory
@@ -278,7 +276,6 @@ func TestACoreBehindWhileTheNodeToolsSettleFailsNoOtherModule(t *testing.T) {
// A machine-level condition about anything else holds the machine back as a whole: everything the send
// moved there fails together, once, with that reason — not one module for it.
func TestAMachineUnhealthyAsAWholeFailsTheWholeSendTogether(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
tm := aTierMesh(t, "app1", "app2", "app3")
ctx := t.Context()
@@ -310,7 +307,6 @@ func TestAMachineUnhealthyAsAWholeFailsTheWholeSendTogether(t *testing.T) {
// The machine-level conditions, sorted: one naming a module is that module's; the core behind is the
// core's when the send moved it and nobody's when it did not; anything else is the machine's as a whole.
func TestTheConditionsAboutAMachineItself(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
since := time.Now().Add(-time.Minute)
about := func(kind, id string) conditions.Condition {
return conditions.Condition{Key: "machine." + id + "." + kind, Kind: kind, Raised: time.Now(),
@@ -343,7 +339,6 @@ func TestTheConditionsAboutAMachineItself(t *testing.T) {
// rollback that failed; and `plans retry` asks it again rather than refusing a build that was never
// judged (the incident's "NOT put back: no build kept").
func TestAModuleItsSendChangedNothingOfIsLeftAsItWasAndRetried(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
g := aGateMesh(t)
ctx := t.Context()
@@ -399,7 +394,6 @@ func TestAModuleItsSendChangedNothingOfIsLeftAsItWasAndRetried(t *testing.T) {
// them, they are registered back and marked in the judging that finds them broken, the plan goes on judging
// the other module to its own pass, and only then fails.
func TestATierPutsABrokenModuleBackAtOnce(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
judgeLeavesAsTheCore(t)
for _, order := range [][]string{{broker.RuntimeModule, "app1"}, {"app1", broker.RuntimeModule}} {
t.Run("gate kept on "+order[0], func(t *testing.T) {
@@ -460,7 +454,6 @@ func TestATierPutsABrokenModuleBackAtOnce(t *testing.T) {
// decision 4): the leaf beside the node tools has its one reading at once, but the send is judged by the tool
// runner's rule: one reading, not before the node-engine's witness window for it has ended.
func TestASendCarryingTheToolRunnerWaitsForItsWitnessWindow(t *testing.T) {
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
tm := aTierMesh(t, broker.RuntimeModule, "app1")
ctx := t.Context()
// One reading counted per advance, as every test with a gate left open does: a gate judged with no time
+1 -6
View File
@@ -23,12 +23,7 @@ import (
// **It holds the stores, not a copy of the decision.** What to do about an upgrade is read when
// one arrives, so changing it takes effect on the next upgrade rather than on the next restart of
// the control plane.
type following struct {
open *stores
// oneShot is a process that ends after the call (a recheck through `delivery check`): what it would do beside
// the call it does within it, or the process's exit would cut it off (novox/hq issue 726).
oneShot bool
}
type following struct{ open *stores }
// Upgraded sends the machines running a module the version the catalogue now considers current —
// or records that they are behind, which is the default and needs no record.
@@ -1,91 +0,0 @@
package main
import (
"context"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A module may declare its unhealthy urgent (novox/hq ADR 0355): its module-unhealthy condition is then raised
// urgent; without the field it stays a warning; a wait for a new login stays a warning whatever the module says.
// judgedTwice judges one machine's statement twice, over a catalogue in which mail declares its unhealthy
// urgent and notes says nothing, and returns what is open.
func judgedTwice(t *testing.T, unhealthy map[string][]inventory.ResourceHealth) []conditions.Condition {
t.Helper()
ctx := t.Context()
k, _ := withConditionsInMemory(t)
was := readHoldingFor
t.Cleanup(func() { readHoldingFor = was })
readHoldingFor = func(_ context.Context, _ *inventory.Inventory, open []conditions.Condition) (*holding, error) {
return &holding{ctx: context.Background(), open: open, providers: map[string]providerLookup{},
shelf: map[string]catalogue.Manifest{
"mail": {Module: "mail", Version: "1", Unhealthy: &catalogue.UnhealthyPolicy{
Severity: catalogue.UnhealthyUrgent, Why: "mail is refused or stuck for everyone with a mailbox"}},
"notes": {Module: "notes", Version: "1"},
}}, nil
}
streaks := map[string]int{}
for look := 1; look <= 2; look++ {
for m := range unhealthy {
streaks[m] = look
}
if err := judgeModuleHealth(ctx, nil, k, "anchor", unhealthy, streaks, time.Now()); err != nil {
t.Fatal(err)
}
}
open, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
return open
}
func containerDown(module string) inventory.ResourceHealth {
return inventory.ResourceHealth{Module: module, Resource: module + ".web", Kind: "container", Target: module,
State: link.StateUnhealthy, Reason: "down"}
}
func TestAModuleThatDeclaresItsUnhealthyUrgentIsRaisedUrgent(t *testing.T) {
open := judgedTwice(t, map[string][]inventory.ResourceHealth{"mail": {containerDown("mail")}})
c := conditionOf(open, "module.mail.anchor.unhealthy")
if c == nil {
t.Fatalf("mail's unhealthy condition was not raised: %+v", open)
}
if c.Severity != conditions.Urgent {
t.Errorf("mail declares its unhealthy urgent, and it was raised %s", c.Severity)
}
}
func TestAModuleThatSaysNothingOfItsUnhealthyStaysAWarning(t *testing.T) {
open := judgedTwice(t, map[string][]inventory.ResourceHealth{"notes": {containerDown("notes")}})
c := conditionOf(open, "module.notes.anchor.unhealthy")
if c == nil {
t.Fatalf("notes' unhealthy condition was not raised: %+v", open)
}
if c.Severity != conditions.Warning {
t.Errorf("notes says nothing of its unhealthy, and it was raised %s", c.Severity)
}
}
// Only the module-unhealthy condition changes (ADR 0355 §4): a wait for a new login (ADR 0254) of a module that
// declares its unhealthy urgent stays a warning.
func TestAWaitForANewLoginStaysAWarningWhenTheModuleDeclaresItsUnhealthyUrgent(t *testing.T) {
open := judgedTwice(t, map[string][]inventory.ResourceHealth{"mail": {relogin("mail", "operator"),
userUnit("mail", "operator")}})
c := conditionOf(open, "module.mail.anchor.relogin-needed")
if c == nil {
t.Fatalf("mail's relogin-needed condition was not raised: %+v", open)
}
if c.Severity != conditions.Warning {
t.Errorf("a wait for a new login was raised %s; want a warning", c.Severity)
}
if conditionOf(open, "module.mail.anchor.unhealthy") != nil {
t.Errorf("a wait for a new login raised mail's unhealthy condition beside it: %+v", open)
}
}
@@ -1,122 +0,0 @@
package main
import (
"reflect"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// novox/hq issue 754: a walk waiting for its delivery's word takes no build's outcome, nor does a walk take one for
// a module it never asked; asked under an id, it takes that id's alone.
func TestAWalkTakesOnlyTheOutcomesOfWhatItAsked(t *testing.T) {
asked := time.Date(2026, 10, 14, 23, 30, 0, 0, time.UTC)
before := asked.Add(-time.Minute)
waiting := inventory.Plan{Delivery: &inventory.PlanDelivery{Awaits: "mesh-delivery"}}
letGo := inventory.Plan{Delivery: &inventory.PlanDelivery{Awaits: "mesh-delivery", Go: &asked}}
own := &inventory.PlanModule{State: "asked", AskedAt: &asked, Build: "build-own"}
old := &inventory.PlanModule{State: "asked", AskedAt: &asked}
for _, c := range []struct {
what string
p inventory.Plan
state *inventory.PlanModule
at time.Time
id string
takes bool
}{
{"a rebuild while the walk waits", waiting, nil, asked, "build-rebuild", false},
{"its own id while it waits", waiting, own, asked, "build-own", false},
{"a module it never asked", letGo, nil, asked, "build-rebuild", false},
{"a module it never asked, state kept empty", letGo, &inventory.PlanModule{}, asked, "build-rebuild", false},
{"another id", letGo, own, asked, "build-rebuild", false},
{"its own id", letGo, own, asked, "build-own", true},
{"a plan from before ids, asked after it", letGo, old, asked.Add(time.Second), "x", true},
{"a plan from before ids, asked before it", letGo, old, before, "x", false},
{"a plan with no delivery, its own id", inventory.Plan{}, own, asked, "build-own", true},
} {
if got := takesOutcome(c.p, c.state, c.at, c.id); got != c.takes {
t.Errorf("%s: takes %v, want %v", c.what, got, c.takes)
}
}
}
// novox/hq issue 754: a tier partly asked asks the rest; a module held built that the plan never asked — the live
// shape, a hand rebuild taken in during the wait, built after the plan was made — is asked again; one asked is kept.
func TestATierPartlyAskedAsksTheRest(t *testing.T) {
created := time.Date(2026, 10, 14, 23, 30, 0, 0, time.UTC)
earlier, later := created.Add(-time.Hour), created.Add(7*time.Minute)
p := inventory.Plan{Created: created, Tiers: [][]string{{"build-agent", "gitea", "route-proxy", "stale", "kept", "own"}},
Modules: map[string]*inventory.PlanModule{
"gitea": {},
"route-proxy": {State: "built", BuiltAt: &later, Commit: "c51357f8"},
"stale": {State: "built", BuiltAt: &earlier},
"kept": {State: "asked", AskedAt: &created, Build: "build-kept"},
"own": {State: "built", AskedAt: &created, BuiltAt: &later, Build: "build-own"},
}}
if got, want := unaskedOf(p), []string{"build-agent", "gitea", "route-proxy", "stale"}; !reflect.DeepEqual(got, want) {
t.Fatalf("unasked %v, want %v", got, want)
}
if !askedInTier(p) {
t.Fatal("a tier with a module asked under an id reads as never asked")
}
p.Modules["kept"], p.Modules["own"] = nil, nil
if askedInTier(p) {
t.Fatal("a tier the plan never asked reads as asked")
}
}
// novox/hq issue 754, as seen on 2026-10-14: a rebuild lands while the walk waits for its word; the walk is let go;
// it asks every module of its tier, and completes it once those builds come in.
func TestARebuildInTheWaitIsNotTheWalksBuild(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
asked := asksRecorded(t)
twoTiers(t, open)
created := time.Now().UTC().Add(-10 * time.Minute)
plan := inventory.Plan{ID: "plan-754", Repository: "novox/a", Branch: "main", Commit: "c0ffee", Created: created,
State: inventory.PlanBuilding, Tiers: [][]string{{"a", "b"}}, Modules: map[string]*inventory.PlanModule{},
Delivery: &inventory.PlanDelivery{Awaits: "mesh-delivery"}}
if err := open.inventory.SavePlan(ctx, &plan); err != nil {
t.Fatal(err)
}
// A rebuild of a, beside the waiting walk: not joined, and its outcome not taken.
joined, said, err := joinAPlan(ctx, open, "a")
if err != nil || joined || said == "" {
t.Fatalf("a rebuild joined a waiting walk: %v %q %v", joined, said, err)
}
planBuilt(ctx, open, "a", "c0ffee", "", time.Now().UTC(), "build-rebuild")
p, _ := open.inventory.PlanByID(ctx, plan.ID)
if s := p.Modules["a"]; s != nil && s.State != "" {
t.Fatalf("the waiting walk took a rebuild as its build: %+v", s)
}
if len(*asked) != 0 {
t.Fatalf("a waiting walk asked %v", *asked)
}
// Let go: both are asked, and their outcomes complete the tier.
if _, err := letGo(ctx, open.inventory, plan.ID, "operator", "test"); err != nil {
t.Fatal(err)
}
advancePlans(ctx, open)
p, _ = open.inventory.PlanByID(ctx, plan.ID)
for _, m := range []string{"a", "b"} {
if s := p.Modules[m]; s == nil || s.State != "asked" || s.Build == "" {
t.Fatalf("let go, %s is %+v", m, s)
}
}
if len(*asked) != 2 {
t.Fatalf("asked %v", *asked)
}
for _, m := range []string{"a", "b"} {
id := p.Modules[m].Build
at, _ := link.BuildAskedAt(id)
planBuilt(ctx, open, m, "c0ffee", "", at, id)
}
advancePlans(ctx, open)
if p, _ = open.inventory.PlanByID(ctx, plan.ID); p.Open() && p.Tier == 0 {
t.Fatalf("the walk did not get past its tier: %s at %d, %q", p.State, p.Tier, p.Note)
}
}
+6
View File
@@ -94,6 +94,9 @@ type signalFacts struct {
advisoriesErr error
// busFaultsErr is why the bus module's events could not be read this tick (novox/hq ADR 0357).
busFaultsErr error
// busWatch is the bus module's watch's newest heartbeat, when the stream holds one (review 274).
busWatch link.BusWatchBeat
busWatchHeard bool
selfCheck selfCheckFacts
@@ -368,6 +371,9 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts {
if faults, f.busFaultsErr = w.server.BusFaults(ctx, now.Add(-advisoryQuiet)); f.busFaultsErr == nil {
f.advisories = link.MergeAdvisories(f.advisories, faults)
}
if f.busFaultsErr == nil {
f.busWatch, f.busWatchHeard, f.busFaultsErr = w.server.BusWatchLast(ctx)
}
}
if f.advisoriesErr == nil && w.js != nil {
f.deadLetters, f.advisoriesErr = link.HeldDeadLetters(w.js.Context())
+2 -3
View File
@@ -38,9 +38,8 @@ func TestTheBuildSeatsHolderFollowsTheControllerThatDefinesItsWorker(t *testing.
if pos["build-agent"] <= pos["mesh-controller"] {
t.Fatalf("the build machine after the controller that defines its worker: %v", tiers)
}
// Built-by orders nothing (novox/hq ADR 0360 §3): whichever builder runs builds the proxy, beside the controller.
if pos["route-proxy"] != 0 {
t.Fatalf("what the builder builds waits for a new one: %v", tiers)
if pos["route-proxy"] <= pos["build-agent"] {
t.Fatalf("what the build machine builds comes after it: %v", tiers)
}
if hasCycle(tiers, edges) {
t.Fatalf("no cycle here: %v", tiers)
+4 -21
View File
@@ -128,13 +128,7 @@ func ConsumerFor(p Principal) (Consumer, bool) {
// connected, and its graph stayed empty with nothing anywhere reporting why.
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
hearsAStream := false
for _, h := range p.Hears {
if len(h.Filters) > 0 {
hearsAStream = true
}
}
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) {
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) {
return Consumer{}, false
}
perms, err := PermissionsFor(p)
@@ -146,14 +140,11 @@ func ConsumerFor(p Principal) (Consumer, bool) {
// business, which is why this is a filter and not the whole list.
var filters []string
for _, s := range perms.Subscribe {
// Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363).
if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) {
if strings.Contains(s, ".event.") {
filters = append(filters, s)
}
}
// **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it
// gives up on there is delivered again under `mesh.again.<consumer>.` on EVENTS, and only this filters it.
if len(filters) == 0 && !hearsAStream {
if len(filters) == 0 {
return Consumer{}, false
}
sort.Strings(filters)
@@ -190,13 +181,6 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
}
seen[p.Node+"/"+p.Module] = true
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
// And one on each module stream it hears (novox/hq ADR 0363 §5).
for _, h := range p.Hears {
if len(h.Filters) == 0 {
continue
}
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)})
}
}
for _, p := range users {
if p.Kind == KindModule {
@@ -209,8 +193,7 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
}
for _, d := range p.Carries {
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
Streams: d.Streams, Hears: d.Hears})
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches})
}
}
return out
-22
View File
@@ -486,25 +486,3 @@ func (j *JetStream) LogStreams() ([]string, error) {
}
return out, lister.Err()
}
// ConsumerNames is every durable consumer on a stream; none for a stream that does not exist yet.
func (j *JetStream) ConsumerNames(stream string) ([]string, error) {
if _, err := j.js.StreamInfo(stream); errors.Is(err, nats.ErrStreamNotFound) {
return nil, nil
} else if err != nil {
return nil, err
}
var out []string
for name := range j.js.ConsumerNames(stream) {
out = append(out, name)
}
return out, nil
}
// RemoveConsumer deletes one durable consumer (novox/hq ADR 0363 §6): one the composition no longer asks for.
func (j *JetStream) RemoveConsumer(stream, name string) error {
if err := j.js.DeleteConsumer(stream, name); err != nil && !errors.Is(err, nats.ErrConsumerNotFound) {
return err
}
return nil
}
-7
View File
@@ -61,11 +61,6 @@ type Membership struct {
// over every module on the machine, so one module's code reaching another's name or kind through
// the runtime is the runtime's to refuse.
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
// Streams are this module's own streams (novox/hq ADR 0363 §2): an event one of them carries is published
// on that stream's subject, never on Emits. Hears are the module streams it hears, each through a durable
// consumer on that stream the runtime binds beside the one on EVENTS (§8).
Streams []StreamIssued `json:"streams,omitempty"`
Hears []HeardIssued `json:"hears,omitempty"`
}
// Served is one address a tool is answered on.
@@ -131,8 +126,6 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
}
m.State = stateIssuedFor(d, node)
m.Logs = logsIssuedFor(d)
m.Streams = streamsIssuedFor(d)
m.Hears = HeardIssuedFor(node, d)
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
if !s.Kinded {
-243
View File
@@ -1,243 +0,0 @@
package broker
import (
"fmt"
"sort"
"strings"
)
// A module's own stream on the bus (novox/hq ADR 0363).
//
// A module declares a stream as it declares a log: named locally, created by the controller from the
// catalogue on every raise and every send, never removed by the mesh. Its events are published on
// `mesh.stream.<module>.event.<event>`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`)
// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share
// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on
// EVENTS. Pure; the controller's raise asserts what is composed here.
// moduleStreamPrefix starts every subject a module's own stream takes.
const moduleStreamPrefix = "mesh.stream."
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
func ModuleStreamSubject(module, event string) string {
return moduleStreamPrefix + module + ".event." + event
}
// OnAModuleStream says a subject is one a module's own stream takes.
func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) }
// WirePattern is a local event pattern as the bus spells it: `**` is `>`.
func WirePattern(local string) string {
parts := strings.Split(local, ".")
for i, p := range parts {
if p == "**" {
parts[i] = ">"
}
}
return strings.Join(parts, ".")
}
// A ModuleStream is one module's declared stream as the bus holds it.
type ModuleStream struct {
Module string
// Name is its name on the bus, `MAIL`.
Name string
// Events are the events it carries, as the bus spells them (WirePattern).
Events []string
MaxAgeDays int
MaxMiB int
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
Restricted bool
}
// Subjects are the subjects the stream takes.
func (s ModuleStream) Subjects() []string {
var out []string
for _, e := range s.Events {
out = append(out, ModuleStreamSubject(s.Module, e))
}
sort.Strings(out)
return out
}
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
// answers for anything older.
func (s ModuleStream) Stream() Stream {
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
if s.Restricted {
why += "; heard only by the consumers its trusted config value grants"
}
return Stream{
Name: s.Name,
Subjects: s.Subjects(),
Retention: RetentionLimits,
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
Why: why,
}
}
// ModuleStreams is every declared module stream as the controller asserts it, in name order.
func ModuleStreams(declared []ModuleStream) []Stream {
sorted := append([]ModuleStream(nil), declared...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
out := make([]Stream, 0, len(sorted))
for _, s := range sorted {
out = append(out, s.Stream())
}
return out
}
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
// (ADR 0363 §5). Composed only with at least one subject.
type Heard struct {
Stream string
Emitter string
Filters []string
}
// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer
// on EVENTS, on that stream, with the subjects it was granted as its filters.
func heardConsumer(p Principal, h Heard) Consumer {
filters := append([]string(nil), h.Filters...)
sort.Strings(filters)
return Consumer{
Name: consumerDurable(p),
Stream: h.Stream,
Filters: filters,
AckWaitSeconds: 30,
MaxDeliver: 5,
Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " +
"(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " +
"DEAD_LETTERS until a person delivers it again or drops it",
}
}
// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams
// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's.
func heardGrants(p Principal, hears []Heard) []string {
var out []string
durable := consumerDurable(p)
for _, h := range hears {
if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 {
continue
}
out = append(out,
"$JS.API.CONSUMER.INFO."+h.Stream+"."+durable,
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable,
"$JS.ACK."+h.Stream+"."+durable+".>")
}
return out
}
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
func ownStreamGrants(module string, streams []ModuleStream) []string {
var out []string
for _, s := range streams {
if s.Module != module {
continue
}
out = append(out, s.Subjects()...)
}
return out
}
// StreamIssued is one of a module's own streams as its membership names it (ADR 0363 §2): the events it
// carries, as the bus spells them, and the subject an event of them is published on, `{event}` the event.
type StreamIssued struct {
Stream string `json:"stream"`
Events []string `json:"events"`
Subject string `json:"subject"`
}
// HeardIssued is one module stream an assignment hears, as its membership names it (ADR 0363 §8): the
// stream and the durable consumer on it the runtime binds.
type HeardIssued struct {
Stream string `json:"stream"`
Consumer string `json:"consumer"`
}
// streamsIssuedFor is every stream of its own a module's membership names.
func streamsIssuedFor(d Declared) []StreamIssued {
var out []StreamIssued
for _, s := range d.Streams {
if s.Module != d.Module || !safeSubject.MatchString(d.Module) {
continue
}
out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...),
Subject: ModuleStreamSubject(d.Module, "{event}")})
}
return out
}
// HeardIssuedFor is every module stream an assignment hears, as its membership names it.
func HeardIssuedFor(node string, d Declared) []HeardIssued {
var out []HeardIssued
for _, h := range d.Hears {
if len(h.Filters) == 0 {
continue
}
out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module})
}
sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream })
return out
}
// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it
// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none.
type Pruner interface {
ConsumerNames(stream string) ([]string, error)
RemoveConsumer(stream, name string) error
}
// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for,
// and answers what it removed as `<stream>/<consumer>`. A consumer that loses its grant loses what it reads
// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove.
func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) {
keep := map[string]bool{}
for _, c := range wanted {
keep[c.Stream+"/"+c.Name] = true
}
var removed []string
for _, s := range streams {
names, err := p.ConsumerNames(s.Name)
if err != nil {
return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err)
}
for _, n := range names {
if keep[s.Name+"/"+n] {
continue
}
if err := p.RemoveConsumer(s.Name, n); err != nil {
return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err)
}
removed = append(removed, s.Name+"/"+n)
}
}
return removed, nil
}
// EnsureHeard asserts a consumer on a module's stream (ADR 0363 §5, §6). **On a restricted stream, a consumer
// the bus will not bring to match is removed**: a pull consumer delivers to whoever pulls it, so one left with
// its wider filters after a narrowing was refused would keep handing over what the grant no longer names,
// whatever the user list now says. The next assertion makes it again with the granted filters. The refusal is
// returned either way, saying what was done, so the send says it and raises it.
func EnsureHeard(r Raiser, c Consumer, restricted bool) error {
err := r.EnsureConsumer(c)
if err == nil || !restricted {
return err
}
p, prunes := r.(Pruner)
if !prunes {
return fmt.Errorf("%w; it could not be removed here, and may still deliver more than it is granted", err)
}
if rerr := p.RemoveConsumer(c.Stream, c.Name); rerr != nil {
return fmt.Errorf("%w; REMOVING IT FAILED TOO, so it may still deliver more than it is granted: %v", err, rerr)
}
return fmt.Errorf("%w; REMOVED, so it delivers nothing it is no longer granted: the next assertion makes it "+
"again with its granted filters (novox/hq ADR 0363 §6)", err)
}
-68
View File
@@ -1,68 +0,0 @@
package broker
import (
"slices"
"testing"
"time"
"github.com/novox/mesh-controller/internal/testbus"
)
// On a real bus: a module's stream is made beside EVENTS without a clash, keeps what the module publishes on
// its own subjects, a granted consumer hears only its part, a narrowed grant narrows it, and a withdrawn one
// is removed (novox/hq ADR 0363).
//
// go test ./internal/broker/ -run TestAModuleStreamOnARealBus
func TestAModuleStreamOnARealBus(t *testing.T) {
js, err := Dial(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
defer js.Close()
for _, s := range []Stream{MeshStreams()[3], mailStream.Stream()} {
_ = js.js.DeleteStream(s.Name)
if err := js.EnsureStream(s); err != nil {
t.Fatalf("%s: %v", s.Name, err)
}
defer func(name string) { _ = js.js.DeleteStream(name) }(s.Name)
}
for _, account := range []string{"mesh", "work"} {
if _, err := js.js.Publish(ModuleStreamSubject("mesh-mail", "mail."+account+".message.added"), []byte("{}")); err != nil {
t.Fatalf("publishing %s's mail: %v", account, err)
}
}
if info, _ := js.js.StreamInfo(EventsStream); info.State.Msgs != 0 {
t.Fatalf("EVENTS kept %d of the module stream's events", info.State.Msgs)
}
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
c := heardConsumer(reader, mailHeard)
if err := js.EnsureConsumer(c); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 1 {
t.Fatalf("the reader granted `mesh` has %d pending, want 1", info.NumPending)
}
narrowed := c
narrowed.Filters = []string{"mesh.stream.mesh-mail.event.mail.mesh.message.changed"}
if err := EnsureHeard(js, narrowed, true); err != nil {
t.Fatalf("narrowing the grant: %v", err)
}
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.Config.FilterSubject != narrowed.Filters[0] || len(info.Config.FilterSubjects) != 0 {
t.Fatalf("narrowed to %q %v, want only %s", info.Config.FilterSubject, info.Config.FilterSubjects, narrowed.Filters[0])
}
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 0 {
t.Fatalf("after narrowing, %d of the reader's former grant are still pending", info.NumPending)
}
removed, err := PruneHeard(js, []ModuleStream{mailStream}, nil)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(removed, []string{"MAIL/ace_reader"}) {
t.Fatalf("removed %v", removed)
}
if names, _ := js.ConsumerNames("MAIL"); len(names) != 0 {
t.Fatalf("still on MAIL: %v", names)
}
}
-210
View File
@@ -1,210 +0,0 @@
package broker
import (
"errors"
"reflect"
"slices"
"strings"
"testing"
)
// mailStream is mesh-mail's own stream as the bus holds it (novox/hq ADR 0363).
var mailStream = ModuleStream{Module: "mesh-mail", Name: "MAIL",
Events: []string{"mail.*.message.added", "mail.*.message.changed", "mail.*.message.removed"},
MaxAgeDays: 7, MaxMiB: 1024, Restricted: true}
// mailHeard is what a reader granted the account `mesh` hears of it.
var mailHeard = Heard{Stream: "MAIL", Emitter: "mesh-mail", Filters: []string{
"mesh.stream.mesh-mail.event.mail.mesh.message.added", "mesh.stream.mesh-mail.event.mail.mesh.message.changed"}}
// A module's stream is its own: subjects EVENTS does not claim, its age and size, kept by limits (ADR 0363 §1, §2).
func TestAModuleStreamTakesSubjectsEventsDoesNot(t *testing.T) {
s := mailStream.Stream()
if s.Name != "MAIL" || s.Retention != RetentionLimits || s.MaxAge != 7*24*60*60 || s.MaxBytes != 1024<<20 || s.DiscardNew {
t.Fatalf("composed as %+v", s)
}
want := []string{"mesh.stream.mesh-mail.event.mail.*.message.added", "mesh.stream.mesh-mail.event.mail.*.message.changed",
"mesh.stream.mesh-mail.event.mail.*.message.removed"}
if !reflect.DeepEqual(s.Subjects, want) {
t.Fatalf("subjects %v", s.Subjects)
}
for _, own := range MeshStreams() {
for _, a := range own.Subjects {
for _, b := range s.Subjects {
if SubjectsOverlap(a, b) {
t.Fatalf("%s claims %s, which overlaps %s of %s: the bus would refuse the stream", s.Name, b, a, own.Name)
}
}
}
}
if WirePattern("mail.**") != "mail.>" {
t.Fatal("`**` is spelled `>` on the wire")
}
}
// The provider publishes its stream's events on their subjects and nowhere on EVENTS for them; a consumer
// granted a part hears it through its own consumer on the stream, and one not granted has nothing there
// (ADR 0363 §2, §5).
func TestTheProviderPublishesItsStreamAndOnlyAGrantedConsumerHearsIt(t *testing.T) {
provider, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "mesh-mail",
Streams: []ModuleStream{mailStream}})
if err != nil {
t.Fatal(err)
}
if !MayPublish(provider, "mesh.stream.mesh-mail.event.mail.work.message.added") {
t.Fatal("the provider may not publish its own stream's event")
}
if MayPublish(provider, "mesh.mod.mesh-mail.event.mail.work.message.added") {
t.Fatal("the provider may publish its stream's event on EVENTS")
}
if MayPublish(provider, "mesh.stream.other.event.mail.work.message.added") {
t.Fatal("the provider may publish on another module's stream")
}
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
perms, err := PermissionsFor(reader)
if err != nil {
t.Fatal(err)
}
for _, s := range []string{"$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader", "$JS.API.CONSUMER.INFO.MAIL.ace_reader",
"$JS.ACK.MAIL.ace_reader.1"} {
if !MayPublish(perms, s) {
t.Errorf("the granted reader may not publish %s", s)
}
}
if !MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.mesh.message.added") ||
MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.work.message.added") {
t.Fatal("the reader's subscription is not what it was granted")
}
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_other") {
t.Fatal("the reader may pull another's consumer")
}
stranger, err := PermissionsFor(Principal{Kind: KindModule, Node: "ace", Module: "stranger",
Consumes: []string{"billing.paid"}})
if err != nil {
t.Fatal(err)
}
if MayPublish(stranger, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") ||
MaySubscribe(stranger, "mesh.stream.mesh-mail.event.mail.mesh.message.added") {
t.Fatal("a module not granted reaches the stream")
}
}
// A granted consumer has a durable consumer on the stream, with its granted subjects as filters, and an EVENTS
// consumer that hears what it gives up on delivered again (ADR 0363 §5, §7); the runtime carrying it may read both.
func TestAGrantedConsumerHasAConsumerOnTheStream(t *testing.T) {
records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{
"novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}},
"ace": {{Module: RuntimeModule}, {Module: "reader", Hears: []Heard{mailHeard}, HearsAStream: true}, {Module: "stranger", HearsAStream: true}},
}}
users, err := Users(records)
if err != nil {
t.Fatal(err)
}
var onMail, onEvents []Consumer
for _, c := range ConsumersOf(users) {
switch c.Consumer.Stream {
case "MAIL":
onMail = append(onMail, c.Consumer)
case EventsStream:
onEvents = append(onEvents, c.Consumer)
}
}
if len(onMail) != 1 || onMail[0].Name != "ace_reader" || !slices.Equal(onMail[0].Filters, mailHeard.Filters) {
t.Fatalf("on MAIL: %+v", onMail)
}
if len(onEvents) != 1 || onEvents[0].Name != "ace_reader" || !slices.Equal(onEvents[0].Filters, []string{AgainFilter("ace_reader")}) {
t.Fatalf("on EVENTS: %+v", onEvents)
}
for _, u := range users {
if u.Kind != KindNodeTools || u.Node != "ace" {
continue
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
if !MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader") || !MayPublish(perms, "$JS.ACK.MAIL.ace_reader.1") {
t.Fatal("the runtime carrying the reader may not read its consumer on MAIL")
}
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") {
t.Fatal("the runtime may read a consumer no module it carries is granted")
}
}
}
// The membership says where a module's stream events go and which consumers on module streams to bind
// (ADR 0363 §2, §8).
func TestTheMembershipNamesTheStreamAndWhatIsHeard(t *testing.T) {
where := Placements{Nodes: map[string][]string{}}
provider := MembershipFor("novox", Declared{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, where)
if len(provider.Streams) != 1 || provider.Streams[0].Stream != "MAIL" ||
provider.Streams[0].Subject != "mesh.stream.mesh-mail.event.{event}" ||
!slices.Equal(provider.Streams[0].Events, mailStream.Events) {
t.Fatalf("the provider's membership: %+v", provider.Streams)
}
reader := MembershipFor("ace", Declared{Module: "reader", Hears: []Heard{mailHeard}}, where)
if !reflect.DeepEqual(reader.Hears, []HeardIssued{{Stream: "MAIL", Consumer: "ace_reader"}}) {
t.Fatalf("the reader's membership: %+v", reader.Hears)
}
if none := MembershipFor("ace", Declared{Module: "stranger", HearsAStream: true}, where); none.Hears != nil {
t.Fatalf("a module not granted is told to bind %+v", none.Hears)
}
}
type fakePruner struct {
on map[string][]string
removed []string
}
func (f *fakePruner) ConsumerNames(stream string) ([]string, error) { return f.on[stream], nil }
func (f *fakePruner) RemoveConsumer(stream, name string) error {
f.removed = append(f.removed, stream+"/"+name)
return nil
}
// A consumer that lost its grant has its consumer on the stream removed by the next assertion (ADR 0363 §6).
func TestAConsumerThatLostItsGrantIsRemoved(t *testing.T) {
f := &fakePruner{on: map[string][]string{"MAIL": {"ace_reader", "ace_former"}}}
wanted := []Consumer{{Name: "ace_reader", Stream: "MAIL"}, {Name: "ace_former", Stream: EventsStream}}
removed, err := PruneHeard(f, []ModuleStream{mailStream}, wanted)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(removed, []string{"MAIL/ace_former"}) || !slices.Equal(f.removed, removed) {
t.Fatalf("removed %v", removed)
}
}
type refusingRaiser struct {
fakePruner
refuse bool
}
func (r *refusingRaiser) EnsureStream(Stream) error { return nil }
func (r *refusingRaiser) EnsureConsumer(Consumer) error {
if r.refuse {
return errors.New("consumer update refused")
}
return nil
}
// A narrowing the bus refuses on a restricted stream removes the consumer, so its wider filters deliver nothing
// more; on an unrestricted stream the refusal is only said (review of PR 338, ADR 0363 §6).
func TestARefusedNarrowingOnARestrictedStreamRemovesTheConsumer(t *testing.T) {
c := Consumer{Name: "ace_reader", Stream: "MAIL", Filters: mailHeard.Filters[:1]}
r := &refusingRaiser{refuse: true}
err := EnsureHeard(r, c, true)
if err == nil || !strings.Contains(err.Error(), "REMOVED") || !slices.Equal(r.removed, []string{"MAIL/ace_reader"}) {
t.Fatalf("restricted: %v, removed %v", err, r.removed)
}
r = &refusingRaiser{refuse: true}
if err := EnsureHeard(r, c, false); err == nil || len(r.removed) != 0 {
t.Fatalf("unrestricted: %v, removed %v", err, r.removed)
}
r = &refusingRaiser{}
if err := EnsureHeard(r, c, true); err != nil || len(r.removed) != 0 {
t.Fatalf("accepted: %v, removed %v", err, r.removed)
}
}
+1 -22
View File
@@ -141,10 +141,6 @@ type Principal struct {
KeyedReads []KeyedRead
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
Logs []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
Hears []Heard
// SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
@@ -641,8 +637,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
for _, e := range p.Emits {
pub = append(pub, own+".event."+e)
}
// And its own streams' events, on their own subjects (novox/hq ADR 0363 §2).
pub = append(pub, ownStreamGrants(p.Module, p.Streams)...)
// Every tool under its own name, not a list: the tools a module serves are what its code
// answers, and a second copy of that list in the manifest would be a second source of
// truth for the mesh to keep in step (2026-09-28: every module that served a tool was
@@ -684,16 +678,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
sub = append(sub, subject)
}
// 2a. What it hears of a module's own stream, as it was granted (novox/hq ADR 0363 §5): the subjects,
// and its own consumer on that stream, asked about, pulled from and acknowledged.
for _, h := range p.Hears {
if len(h.Filters) == 0 {
continue
}
sub = append(sub, h.Filters...)
}
pub = append(pub, heardGrants(p, p.Hears)...)
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
// role is hearing what it announced, not taking part in it.
for _, w := range p.Watches {
@@ -816,8 +800,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
for _, e := range d.Emits {
pub = append(pub, own+".event."+e)
}
// And its own streams' events (novox/hq ADR 0363 §2).
pub = append(pub, ownStreamGrants(d.Module, d.Streams)...)
for _, s := range d.Holds {
if servedOnlyByTheController(s) {
continue
@@ -856,10 +838,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
// consumer is still the controller's to make, from the module's own principal.
for _, d := range p.Carries {
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
Streams: d.Streams, Hears: d.Hears}
// And the module streams it hears, each through its own consumer there (novox/hq ADR 0363 §8).
pub = append(pub, heardGrants(own, d.Hears)...)
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches}
if _, consumes := ConsumerFor(own); !consumes {
continue
}
@@ -1,23 +0,0 @@
package broker
import (
"slices"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The catalogue's list of the mesh's own stream names, which a module's stream may not take, is the broker's
// own list (novox/hq ADR 0363 §1): read from MeshStreams, so a stream added there cannot be forgotten there.
func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) {
var own []string
for _, s := range MeshStreams() {
own = append(own, s.Name)
}
reserved := append([]string(nil), catalogue.MeshStreamNames...)
slices.Sort(own)
slices.Sort(reserved)
if !slices.Equal(own, reserved) {
t.Fatalf("the broker's own streams are %v, the catalogue reserves %v", own, reserved)
}
}
+1 -8
View File
@@ -40,13 +40,6 @@ type Declared struct {
State []Bucket
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
Logs []Log
// Streams are the streams it owns, each its events' one place (novox/hq ADR 0363).
Streams []ModuleStream
// Hears are the module streams it hears on this machine, each with the subjects it was granted
// (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted
// or not, so its membership is issued again when a grant changes.
Hears []Heard
HearsAStream bool
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
Reads []string
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
@@ -136,7 +129,7 @@ func Users(r Records) ([]Principal, error) {
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
Logs: logNames(d.Logs), Streams: d.Streams, Hears: d.Hears,
Logs: logNames(d.Logs),
})
}
if runtimeHere {
+1 -51
View File
@@ -162,10 +162,6 @@ type Reuse struct {
// keyFirst is a check's build (§1): its key is taken before anything is built, and a module with none is not
// built at all — nothing could ever take it.
keyFirst bool
// pull and head are the check's own pull request and the head it judged (novox/hq issue 703): it keeps only a
// build an earlier check of the same pull request at the same head made.
pull PullRef
head string
}
// errNoKey is a check's build of a module whose source does not pin it: not built (ADR 0350 §1).
@@ -378,7 +374,7 @@ func build(ctx context.Context, run Runner, publish Publisher,
// only when asked — a check, a dry run, `build --ref` and `rebuild` never are. Its key is taken by the same
// steps a build takes, stopped before anything is built; what it takes is copied by digest.
if reuse != nil && reuse.keyFirst {
keyed, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
_, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
held, npmrcPath, npmrc, seatBases, src, say)
if err == nil && key.Key == "" {
err = fmt.Errorf("%w: %s", errNoKey, key.Why)
@@ -387,11 +383,6 @@ func build(ctx context.Context, run Runner, publish Publisher,
say("reuse", "not built for the merge: %v", err)
return Result{Commit: commit, NoKey: err.Error()}, err
}
// **A check keeps a build of the same key a verdict names** (novox/hq issue 703): asked again when only
// the trunk moved, it builds nothing the earlier check built of an unchanged source.
if kept := keptCheckBuild(ctx, *reuse, manifest.Module, key.Key, len(artifacts), say); kept != nil {
built, src, reused = kept.Made, keyed, kept
}
} else if reuse != nil && reuse.Scratch != nil {
taken, keyed, why := takeACheckBuild(ctx, run, publish, *reuse, manifest, within, workspace, commit,
credentials, artifacts, args, held, npmrcPath, npmrc, seatBases, src, onTrunk, say)
@@ -501,47 +492,6 @@ func takeACheckBuild(ctx context.Context, run Runner, publish Publisher, reuse R
return taken, keyed, ""
}
// keptCheckBuild is the build a check keeps instead of building (novox/hq issue 703): the one kept under the
// module's key by an earlier check of the same pull request at the same head — only the trunk moved — when a verdict
// of the build seat names its key and every digest — the record alone is never trusted (ADR 0350 §2) — and it holds
// every artifact the module declares. Another pull request's build, or an earlier head's, is never kept. Nil, and
// the check builds, otherwise.
func keptCheckBuild(ctx context.Context, reuse Reuse, module, key string, artifacts int,
say func(step, format string, args ...any)) *Reused {
if reuse.Scratch == nil || reuse.head == "" {
return nil
}
var same []NamedBuild
for _, n := range reuse.Named {
if n.Pull == reuse.pull {
same = append(same, n)
}
}
if len(same) == 0 {
return nil
}
found, ok, err := reuse.Scratch.Find(ctx, module, key)
switch {
case err != nil:
say("reuse", "the scratch namespace could not be read, so the check builds: %v", err)
return nil
case !ok:
return nil
case found.Pull != reuse.pull || found.Head == "" || twelve(found.Head) != twelve(reuse.head):
say("reuse", "the build kept under %s is %s, not this pull request's at this head: built", twelve(key[3:]),
found.Said())
return nil
case unnamed(same, module, key, found) != "":
say("reuse", "a build is kept under %s and no verdict names it with its digests: built", twelve(key[3:]))
return nil
case len(found.Made) != artifacts:
return nil
}
kept := &Reused{From: found, Made: found.Made}
say("reuse", "kept, not built: %s", strings.Replace(kept.Words(), "reused from", "the build of", 1))
return kept
}
// keyFor takes a build's reuse key by the steps a build takes, each artifact stopped before anything is built or
// published (ADR 0350 §2): its own copy of the source inputs, so the build's are untouched.
func keyFor(ctx context.Context, run Runner, publish Publisher, manifest catalogue.Manifest,
+40 -616
View File
@@ -13,9 +13,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-controller/internal/artifacts"
@@ -89,19 +87,6 @@ type CheckSpec struct {
// Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator"
// the running one with the change's validator.
Judge string
// Release is the release branch the pull request merges into (novox/hq ADR 0358), empty for any other base. The
// gate is then judged by the controller beside it as mesh-controller — mesh-controller's release branch, never
// the controller the mesh runs nor its main — with mesh-host's validator from beside it when mesh-host was read at
// the release branch too; a change to the controller still judges itself and a change to the node-engine brings
// its own validator. The verdict names the branch, the judge's commit and the branch each beside was read at.
Release string
// JudgeCache is the judge cache (novox/hq ADR 0358 §6): a directory of the build agent's own, outside the
// workspace, that only judge builds mount — never a check's containers, so no pull request's script can write it.
// It holds the Go build cache every judge is built with (judges stay warm), the release-branch judges kept by the
// commits they are built from (judges/, one per release-branch head), and each judge build's fresh home (homes/,
// removed after the build). Empty: each judge is built cold under the check's own root, which every check empties
// before it clones, and nothing is kept.
JudgeCache string
// Toolchain is the image a check's Go runs in: the mesh's own Go toolchain, as it holds it.
// Toolchains is every toolchain the mesh holds, by language, for a script that declares another.
Toolchain string
@@ -168,12 +153,10 @@ func ToolchainsOf(held map[string]string) map[string]string {
return out
}
// Beside is one repository cloned next to the one checked: at Ref, or at Else when Else is said and the repository
// has no branch Ref (novox/hq ADR 0358 §3).
// Beside is one repository cloned next to the one checked.
type Beside struct {
Repository string
Ref string
Else string
}
// CheckVerdict is what came of one check. Verdict and Summary are the gate's; Gate and Repo each layer.
@@ -196,16 +179,6 @@ type CheckVerdict struct {
Members []JudgedHead
// Built are the builds it made for its merge (novox/hq ADR 0350 §1), each with its key, or why not.
Built []CheckBuilt
// Release is the release branch it was judged for (novox/hq ADR 0358 §4), JudgedBy the commit of the controller
// that judged its gate (twelve characters), and BesideAt the branch each repository beside it was read at, by
// directory. Empty for a check of any other base.
Release string
JudgedBy string
BesideAt map[string]string
// Validator says which node-engine validator the judge of a release-branch check was built with (ADR 0358 §2):
// mesh-host's of the release branch with its commit, the one the release branch's controller vendors, the
// change's own, or the one the change's own controller vendors. Empty for a check of any other base.
Validator string
}
// Layer is one layer of a check, judged.
@@ -301,17 +274,7 @@ func ScriptToolchain(script []byte) string {
// Check runs one merge check. An error is that it could not run; the verdict is then "error".
func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry string, forge GitCredential,
log Log) (CheckVerdict, error) {
// Everything a check says goes to the build's log, which the bus keeps: said redacted (novox/hq issue 462). A line
// at a time: its builds for the merge say theirs while its tests run (novox/hq issue 716).
if log != nil {
var saying sync.Mutex
inner := log
log = func(step, message string) {
saying.Lock()
defer saying.Unlock()
inner(step, message)
}
}
// Everything a check says goes to the build's log, which the bus keeps: said redacted (novox/hq issue 462).
redact := redactorFor(forge)
say := logging(func(step, message string) {
if log != nil {
@@ -340,32 +303,13 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
if err := forgetLeftCredentials(workspace); err != nil {
return CheckVerdict{}, err
}
// **mesh-lab's replays run where no pull request reaches** (novox/hq issue 709, ADR 0358 §6): they hold the
// container runtime's socket, so their sources, home, Go cache and module cache are none of the workspace's.
replay, err := replayDirs(spec.JudgeCache, workspace, root, spec.ID)
if err != nil {
return CheckVerdict{}, err
}
if replay.base != "" {
defer os.RemoveAll(replay.base)
}
// cloneAt clones a repository and checks out ref — or, when the repository has no branch ref and orElse is said,
// orElse (novox/hq ADR 0358 §3); readAt keeps which, by directory.
readAt := map[string]string{}
cloneAt := func(repository, ref, orElse, dir string) error {
clone := func(repository, ref, dir string) error {
if _, err := run(ctx, root, "git", cloneWith(credentials, "clone", "--quiet", repository, dir)...); err != nil {
return fmt.Errorf("cannot clone %s: %s", redact.redact(repository), redact.redact(err.Error()))
}
if err := recordedWithoutUserinfo(ctx, run, filepath.Join(root, dir), repository); err != nil {
return err
}
if orElse != "" && ref != "" {
if _, err := run(ctx, filepath.Join(root, dir), "git", "rev-parse", "--verify", "--quiet",
"refs/remotes/origin/"+ref); err != nil {
ref = orElse
}
}
readAt[dir] = ref
if ref != "" {
if _, err := run(ctx, filepath.Join(root, dir), "git", "checkout", "--quiet", ref); err != nil {
return fmt.Errorf("%s has no %s: %w", redact.redact(repository), ref, err)
@@ -373,9 +317,6 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
}
return nil
}
clone := func(repository, ref, dir string) error {
return cloneAt(repository, ref, "", dir)
}
name := spec.Repo
if name == "" || !safeName.MatchString(name) {
name = "checked"
@@ -411,28 +352,8 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
}
named := func(v CheckVerdict) CheckVerdict {
v.Head, v.Trunk, v.Merge, v.Beside, v.Members = judged.Head, judged.Trunk, judged.Merge, judged.Beside, judged.Members
v.Release, v.JudgedBy, v.BesideAt, v.Validator = spec.Release, judged.JudgedBy, judged.BesideAt, judged.Validator
return v
}
// **What the merge would move is built from a checkout nothing of the check touches** (novox/hq issue 703): the
// gate and the repository's own check run in containers that mount the workspace read-write, so they can change
// its tree, its objects and its caches. The builds are made from a copy of the merge's objects, cloned before
// anything of the change runs, outside the workspace — so what is kept under a key is what the key describes. Each
// build's working copy is cloned from it into the workspace only after the check has ended (forTheMerge): the
// host's container engine sees no other directory of the holder's (novox/hq issue 713).
buildingTree := ""
if len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0 {
at := judged.Merge
if at == "" {
at = spec.Ref
}
var building string
building, buildingTree, err = checkoutForTheBuilds(ctx, run, tree, at, workspace)
if err != nil {
return CheckVerdict{}, err
}
defer os.RemoveAll(building)
}
// **The base branch's check judges the change** (novox/hq issue 310), never the one the change brings.
judging, err := TheCheckThatJudges(ctx, tree, spec.Base)
if err != nil {
@@ -455,35 +376,9 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
if dir == name || !safeName.MatchString(dir) {
continue
}
if dir == "mesh-lab" {
// **mesh-lab is never read at a release branch** (novox/hq ADR 0358): its replays run with the container
// runtime's socket, so a branch of a release's name — which anyone may create — is never what runs there,
// whether it is asked with a fallback or without.
if b.Else != "" || (spec.Release != "" && b.Ref == spec.Release) {
b.Ref = b.Else
if b.Ref == "" {
b.Ref = "main"
}
}
b.Else = ""
}
if err := cloneAt(b.Repository, b.Ref, b.Else, dir); err != nil {
if err := clone(b.Repository, b.Ref, dir); err != nil {
return CheckVerdict{}, fmt.Errorf("beside it, %w", err)
}
if dir == "mesh-lab" {
// The copy the replays run from, made before anything of the change runs, outside every check container
// (novox/hq issue 709) — and whether it holds replays, read now, so nothing written later decides it.
if err := replay.copyLab(ctx, run, filepath.Join(root, dir)); err != nil {
return CheckVerdict{}, fmt.Errorf("mesh-lab could not be copied for its replays: %w", err)
}
}
if spec.Release != "" {
// The branch each was read at is named with its commit (ADR 0358 §4).
if judged.BesideAt == nil {
judged.BesideAt = map[string]string{}
}
judged.BesideAt[dir] = readAt[dir]
}
// The commit each one read is named in the verdict (ADR 0323 §3): its move is issue 568's question.
if at := headAt(ctx, run, filepath.Join(root, dir)); at != "" {
if judged.Beside == nil {
@@ -491,7 +386,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
}
judged.Beside[dir] = at
}
say("check", "beside it %s at %s", dir, refWords(readAt[dir]))
say("check", "beside it %s at %s", dir, short(b.Ref))
}
// A delivery group's other heads (novox/hq ADR 0239), each at its head, for the gate to compose with this.
@@ -543,25 +438,6 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
// Every clone is made: the credential is gone before anything of the check runs (novox/hq issue 462).
forget()
// **What the merge moves is built beside the tests** (novox/hq issue 716) when the build agent has a judge cache to
// build it in, where no container of the check reaches; without one, after the check has judged.
logged := func(step, message string) {
if log != nil {
log(step, redact.redact(message))
}
}
var beside *besideTheTests
if buildingTree != "" {
place, err := buildsBesideTheTests(spec.JudgeCache, workspace, spec.ID)
if err != nil {
return CheckVerdict{}, err
}
if place != "" {
beside = startBesideTheTests(ctx, run, spec, place, buildingTree, judged, forge, logged, say)
defer beside.abandon()
}
}
// The facts, and the versions they say the mesh runs.
if registry == "" {
return CheckVerdict{}, errors.New("no artifact store to read the facts snapshot from")
@@ -624,56 +500,21 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
user = fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())
}
in := func(image, dir string, env []string, command ...string) []string {
home := workspace
for _, e := range env {
if strings.HasPrefix(e, "HOME=") {
home = strings.TrimPrefix(e, "HOME=")
}
}
// As the builder itself: what a check writes into the workspace is the builder's to remove.
args := []string{"run", "--rm", "--network", "host", "--volume", workspace + ":" + workspace, "--workdir", dir,
"--user", user, "--env", "HOME=" + home,
"--user", user, "--env", "HOME=" + workspace,
// The check's own checkouts, whoever cloned them: git in a container of another user than the
// one that cloned refuses a repository it does not own ("dubious ownership"), and Go's build
// stamps the version from git — a judge that would not build for want of it.
"--env", "GIT_CONFIG_COUNT=1", "--env", "GIT_CONFIG_KEY_0=safe.directory", "--env", "GIT_CONFIG_VALUE_0=*"}
for _, e := range env {
if !strings.HasPrefix(e, "HOME=") {
args = append(args, "--env", e)
}
args = append(args, "--env", e)
}
return append(append(args, image), command...)
}
inToolchain := func(dir string, env []string, command ...string) []string {
return in(spec.Toolchain, dir, env, command...)
}
// **Every judge is built where no pull request can reach** (novox/hq issue 482, ADR 0358 review): the workspace's
// Go cache, HOME and git configuration are written by pull requests' own scripts, and git runs during a Go build
// to stamp its version — a planted hook would rewrite the judge's sources before they compile. So a judge is built
// with a HOME and configuration made fresh for this check and a Go cache only judge builds mount (judgeBuildDirs),
// no global or system git configuration, no Go environment file, and no version stamping from git.
judgeHome, judgeGoCache, err := judgeBuildDirs(spec.JudgeCache, workspace, root, spec.ID)
if err != nil {
return CheckVerdict{}, err
}
defer os.RemoveAll(judgeHome)
inJudgeBuild := func(dir string, _ []string, command ...string) []string {
args := in(spec.Toolchain, dir, JudgeBuildEnv(judgeHome, judgeGoCache), command...)
if spec.JudgeCache == "" {
return args // under the check's root, in the workspace mounted already
}
// The judge cache's parts this build needs, mounted into judge builds alone (ADR 0358 §6).
return append([]string{args[0], "--volume", judgeHome + ":" + judgeHome, "--volume",
judgeGoCache + ":" + judgeGoCache}, args[1:]...)
}
inReplays := func(env []string, command ...string) []string {
args := in(spec.Toolchain, filepath.Join(replay.lab, "replays"), append(ReplayEnv(replay.home, replay.goCache,
replay.modCache), env...), command...)
// The socket goes to the replays alone, never to the change's own code.
mounts := []string{"--volume", "/var/run/docker.sock:/var/run/docker.sock", "--volume", replay.base + ":" +
replay.base, "--volume", replay.goCache + ":" + replay.goCache, "--volume", replay.modCache + ":" + replay.modCache}
return append(append([]string{args[0]}, mounts...), args[1:]...)
}
var out tail
// running runs one container of the check, its output into the report, in a process group of its own.
running := func(args []string) error {
@@ -685,23 +526,10 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
// The judge. Its failing to build is the change's fault when the change is the judge or its validator,
// and the check's when it is the controller the mesh runs.
gate, judgeFault, err := judgeFor(ctx, labelled, run, spec, root, tree, judged.BesideAt, inJudgeBuild, say)
gate, judgeFault, err := judgeFor(ctx, labelled, run, spec, root, tree, inToolchain, say)
if err != nil {
return CheckVerdict{}, err
}
if spec.Release != "" {
// **A release-branch judgement says so** (novox/hq ADR 0358 §4): the branch, the controller's commit that
// judged, and each repository read beside it at its branch and commit.
judgedBy := filepath.Join(root, "mesh-controller")
if spec.Judge == "self" {
judgedBy = tree
}
judged.JudgedBy = headAt(ctx, run, judgedBy)
judged.Validator = releaseValidator(spec, judged.BesideAt, judged.Beside)
said := releaseWords(spec.Release, judged.JudgedBy, judged.Validator, judged.BesideAt, judged.Beside)
fmt.Fprintf(&out, "--- %s\n", said)
say("check", "%s", said)
}
verdictFile := filepath.Join(root, "verdict.json")
env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL,
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
@@ -727,7 +555,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
say("check", "the gate: %d module(s) of the graph touched — %s", len(modules), strings.Join(modules, ", "))
fmt.Fprintf(&out, "--- the gate: %s\n", strings.Join(modules, ", "))
v.Gate.Verdict, v.Gate.Summary = gateLayer(ctx, spec, tree, root, gate, verdictFile, env, inToolchain,
running, &out, bus, name, replay.hasReplays, replay.refused(), inReplays, say)
running, &out, bus, workspace, name, say)
}
if timedOut() {
v.Gate.Verdict, v.Gate.Summary = "error", fmt.Sprintf("the check ran past %s and was ended", CheckTimeout)
@@ -767,11 +595,6 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
}
}
if spec.Release != "" {
// The statuses name the release branch and the judge first, so the forge's short description keeps them.
mark := releaseMark(spec.Release, judged.JudgedBy)
v.Gate.Summary, v.Repo.Summary = mark+v.Gate.Summary, mark+v.Repo.Summary
}
// What the check printed travels in the verdict to the forge and the controller: redacted as the log is.
v.Gate.Summary, v.Repo.Summary, v.Repo.Failed = redact.redact(v.Gate.Summary), redact.redact(v.Repo.Summary),
redact.redact(v.Repo.Failed)
@@ -782,68 +605,24 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
// **What the merge would move, built once, here** (novox/hq ADR 0350 §1): after the judging, from the merge it
// judged, into the scratch namespace — so the walk of the merge commit takes it rather than building again.
switch {
case beside != nil:
v.Built = beside.settle(ctx, v)
case len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0:
v.Built = forTheMerge(ctx, run, spec, v, workspace, buildingTree, judged, forge, logged, say)
if len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0 {
if why := skipsTheBuilds(v); why != "" {
say("check", "%s", why)
} else {
began := time.Now()
v.Built = buildForTheMerge(ctx, run, spec, *spec.BuildWith, workspace, tree, judged.Merge, judged, forge,
func(step, message string) {
if log != nil {
log(step, redact.redact(message))
}
}, say)
say("check", "built for the merge: %d of %d module(s) (%s)", builtCount(v.Built), len(v.Built),
time.Since(began).Round(time.Second))
}
}
return named(v), nil
}
// forTheMerge builds what the merge moves once the check has judged it, from merged — the copy of the merge
// checkoutForTheBuilds made outside the workspace — **in the builder's own workspace** (novox/hq issue 713). A build's
// containers are run by the host's container engine, which mounts a path as the host names it: the build seat's
// holder runs in a container that shares only its workspace with the host, at the same path, so a working copy in
// its own temporary directory reaches the toolchain as an empty directory ("go.mod file not found"), every check
// build failed, and every walk built again. The copy stays outside the workspace, so nothing the tests ran reaches it;
// the working copy cloned from it is made only now, after every container of the check has ended.
func forTheMerge(ctx context.Context, run Runner, spec CheckSpec, verdict CheckVerdict, workspace, merged string,
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) []CheckBuilt {
if why := skipsTheBuilds(verdict); why != "" {
say("check", "%s", why)
return nil
}
began := time.Now()
built := buildForTheMerge(ctx, run, spec, *spec.BuildWith, workspace, merged, judged.Merge, judged, forge, log, say)
say("check", "built for the merge: %d of %d module(s) (%s)", builtCount(built), len(built),
time.Since(began).Round(time.Second))
return built
}
// checkoutForTheBuilds is a clone of the commit a check builds for its merge, made before anything of the check runs,
// outside the workspace its containers mount (novox/hq issue 703): the directory to remove, and the clone in it.
// Cloned over git's own transport, never by linking the objects: what the check's containers later write into the
// workspace's objects cannot reach it.
func checkoutForTheBuilds(ctx context.Context, run Runner, tree, at, workspace string) (string, string, error) {
dir, err := os.MkdirTemp("", "mesh-check-builds-")
if err != nil {
return "", "", err
}
if withinDir(workspace, dir) {
os.RemoveAll(dir)
return "", "", fmt.Errorf("the temporary directory %s is inside the workspace %s, which a check's containers "+
"mount: the merge is not built from where its tests could change it", dir, workspace)
}
commit, err := run(ctx, tree, "git", "rev-parse", "--verify", at+"^{commit}")
if err != nil {
os.RemoveAll(dir)
return "", "", fmt.Errorf("the commit to build for the merge, %s: %w", at, err)
}
commit = strings.TrimSpace(commit)
if _, err := run(ctx, tree, "git", "update-ref", "refs/heads/mesh-check-builds", commit); err != nil {
os.RemoveAll(dir)
return "", "", err
}
cloned := filepath.Join(dir, "merged")
if _, err := run(ctx, dir, "git", "clone", "--quiet", "--no-local", "--no-checkout", "--branch",
"mesh-check-builds", tree, cloned); err != nil {
os.RemoveAll(dir)
return "", "", fmt.Errorf("cannot copy the merge to build it: %w", err)
}
return dir, cloned, nil
}
// builtCount is how many of a check's builds for its merge were built.
func builtCount(built []CheckBuilt) int {
n := 0
@@ -1015,9 +794,8 @@ const SomeManifestsEnv = "MESH_MODULE_CHECK_SOME=1"
// gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the
// change, and the replays of what the mesh runs. It answers the gate's verdict and summary.
func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string,
inToolchain func(string, []string, ...string) []string, running func([]string) error, out *tail, bus,
name string, hasReplays bool, replaysRefused string, inReplays func([]string, ...string) []string,
say func(step, format string, args ...any)) (string, string) {
inToolchain func(string, []string, ...string) []string, running func([]string) error, out *tail, bus, workspace,
name string, say func(step, format string, args ...any)) (string, string) {
// 1. The manifests the change touches, as the judge reads them: a manifest it cannot read, or one with a
// problem the change brings, fails here. **What was already so on the base branch is said and fails
// nothing** — the gate's own rule: a module whose manifest the running controller already finds fault
@@ -1109,37 +887,24 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
// 3. **The replays of what the mesh runs** (to-be 45 §9, M9): mesh-lab's, from its main — reviewed code,
// so given the container runtime the resolver replay raises containers with — against the bus of the
// release the mesh runs and the change's own catalogue when the change is to the catalogue.
if ctx.Err() == nil && hasReplays && replaysRefused != "" {
// **Never from the workspace** (novox/hq issue 709): without the judge cache there is no place outside it.
say("check", "%s", replaysRefused)
fmt.Fprintf(out, "--- the replays: %s\n", replaysRefused)
return "error", replaysRefused
}
if ctx.Err() == nil {
if hasReplays {
if lab := filepath.Join(root, "mesh-lab", "replays"); ctx.Err() == nil {
if _, err := os.Stat(lab); err == nil {
catalogue := filepath.Join(root, "mesh-catalog")
if name == "mesh-catalog" {
catalogue = tree
}
say("check", "the replays of what the mesh runs, from mesh-lab")
fmt.Fprintln(out, "--- the replays (mesh-lab replays/)")
// In the replays' own environment (inReplays): reviewed code, the socket, and nothing the workspace holds
// but the catalogue they read.
args := inReplays([]string{EnvTestBus + "=nats://" + bus, "MESH_REPLAY_CATALOGUE=" + catalogue},
args := inToolchain(lab, []string{EnvTestBus + "=nats://" + bus, "MESH_REPLAY_CATALOGUE=" + catalogue,
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")},
"go", "test", "-count=1", "./...")
// The socket goes to the replays alone, never to the change's own code above.
args = append([]string{args[0], "--volume", "/var/run/docker.sock:/var/run/docker.sock"}, args[1:]...)
if err := running(args); err != nil {
if ctx.Err() != nil {
return "error", "the check was ended during the replays"
}
if replaysNotStarted(err) {
// The container runtime could not start them (a mount, an image): the check's fault, never the change's.
said := "the replays could not be started, so the change is not judged by them: " + lastLine(out.String())
say("check", "%s", said)
return "error", said
}
said := "a replay of a core incident fails with this change: " + lastLine(out.String())
say("check", "%s", said)
return "fail", said
return "fail", "a replay of a core incident fails with this change: " + lastLine(out.String())
}
}
}
@@ -1154,32 +919,21 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
// the mesh runs. It answers the judge's path; or, when the change makes its own judge unbuildable, why —
// the change's fault, a failing gate; or an error when the check cannot build a judge at all. A check
// whose gate does not run builds a judge only to hand its scripts one, and goes on without when it cannot.
//
// **A pull request into a release branch is judged by that branch's controller** (novox/hq ADR 0358 §2): the one
// beside it as mesh-controller, read at the release branch — with mesh-host's validator from beside it when mesh-host
// has that branch too, or with the change's own validator for a change to the node-engine — never the controller the
// mesh runs. besideAt is the branch each repository beside it was read at.
func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, tree string, besideAt map[string]string,
func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, tree string,
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
gated := spec.Gated()
switch spec.Judge {
case "self":
bin := filepath.Join(root, "bin", "judge-of-itself")
if _, err := labelled(ctx, root, "docker", inToolchain(tree, nil,
if _, err := labelled(ctx, root, "docker", inToolchain(tree,
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
"go", "build", "-o", bin, "./cmd/mesh-controller")...); err != nil {
return "", "the change's controller, which judges itself, does not build: " + firstLine(err.Error()), nil
}
say("check", "judged by the change's own controller")
return bin, "", nil
}
var bin, judgeTree string
var err error
if spec.Release != "" {
withHost := spec.Judge != "validator" && besideAt["mesh-host"] == spec.Release
bin, judgeTree, err = releaseJudge(ctx, labelled, run, spec, root, withHost, inToolchain, say)
} else {
bin, judgeTree, err = judge(ctx, labelled, run, root, inToolchain, say)
}
bin, judgeTree, err := judge(ctx, labelled, run, root, inToolchain, say)
if err != nil {
if !gated {
say("check", "no judge for its script: %v", err)
@@ -1198,346 +952,15 @@ func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, t
}
}
withValidator := filepath.Join(root, "bin", "judge-with-this-validator")
if _, err := labelled(ctx, root, "docker", inToolchain(filepath.Join(root, judgeTree), nil,
if _, err := labelled(ctx, root, "docker", inToolchain(filepath.Join(root, judgeTree),
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
"go", "build", "-o", withValidator, "./cmd/mesh-controller")...); err != nil {
if spec.Release != "" {
return "", spec.Release + "'s controller does not build with this change's validator: " +
firstLine(err.Error()), nil
}
return "", "the controller the mesh runs does not build with this change's validator: " + firstLine(err.Error()), nil
}
if spec.Release != "" {
say("check", "judged by %s's controller, with this change's validator", spec.Release)
return withValidator, "", nil
}
say("check", "judged by the controller the mesh runs, with this change's validator")
return withValidator, "", nil
}
// releaseJudge builds the controller that judges a pull request into a release branch (novox/hq ADR 0358 §2): the
// one beside the check as mesh-controller, read at that branch — with mesh-host's validator from beside it in place
// of the one it vendors when withHost. **Built once per release-branch head**: kept in the spec's judge cache by the
// commits it is built from and taken from there while they stand. It answers the binary and the directory it was
// built from.
func releaseJudge(ctx context.Context, run, plain Runner, spec CheckSpec, root string, withHost bool,
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
const dir = "mesh-controller"
if _, err := os.Stat(filepath.Join(root, dir)); err != nil {
return "", "", fmt.Errorf("no controller of %s beside the check to judge it with", spec.Release)
}
key := fullHeadAt(ctx, plain, filepath.Join(root, dir))
if key == "" {
return "", "", fmt.Errorf("the controller of %s beside the check names no commit", spec.Release)
}
with := ""
if withHost {
host := fullHeadAt(ctx, plain, filepath.Join(root, "mesh-host"))
if host == "" {
return "", "", fmt.Errorf("mesh-host of %s beside the check names no commit", spec.Release)
}
key += "-with-mesh-host-" + host
with = ", with mesh-host's validator of " + spec.Release + " at " + twelve(host)
}
bin := filepath.Join(root, "bin", "mesh-controller")
if took := judgeKept(keptJudges(spec.JudgeCache), key, bin); took {
say("check", "judged by %s's controller at %s%s, built for an earlier check of that head", spec.Release,
twelve(key), with)
return bin, dir, nil
}
if withHost {
vendored := filepath.Join(root, dir, "vendor", "github.com", "novox", "mesh-host")
for _, pkg := range []string{"validate", filepath.Join("internal", "declaration")} {
if err := replaceGoFiles(filepath.Join(root, "mesh-host", pkg), filepath.Join(vendored, pkg)); err != nil {
return "", "", fmt.Errorf("mesh-host's validator of %s could not be put in its controller: %w", spec.Release, err)
}
}
}
// Built where no pull request reaches (JudgeBuildEnv): a judge kept for this head is the head's, nothing else.
if _, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir), nil,
"go", "build", "-o", bin, "./cmd/mesh-controller")...); err != nil {
return "", "", fmt.Errorf("the controller of %s does not build%s: %w", spec.Release, with, err)
}
// Kept before anything of the change runs: nothing a pull request's script writes can reach the cache.
keepJudge(keptJudges(spec.JudgeCache), key, bin, say)
say("check", "judged by %s's controller at %s%s", spec.Release, twelve(key), with)
return bin, dir, nil
}
// JudgeBuildEnv is the environment every judge is built in (novox/hq ADR 0358 §6, issue 482): home, made fresh for the
// build, as HOME and configuration; goCache, the judge cache's Go cache that no check's container mounts; no global
// or system git configuration; no Go environment file; and no version stamped from git, so no git runs at all.
func JudgeBuildEnv(home, goCache string) []string {
return []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GOENV=off",
"GOCACHE=" + goCache, "GOPATH=" + filepath.Join(home, "go"),
"CGO_ENABLED=0", "GOFLAGS=-mod=vendor -buildvcs=false", "GOPROXY=off"}
}
// judgeBuildDirs are the home and the Go cache a check's judge builds use (ADR 0358 §6). With a judge cache: a home
// made fresh under its homes/ for this check, and its go-cache/, kept warm between checks — both on the host path the
// build agent declares, so the container runtime mounts what the agent sees, and nothing is left in a temporary
// directory of the host. Without one: both under the check's root, which every check empties before it clones. A
// judge cache inside the workspace is refused: a check's containers mount the workspace.
func judgeBuildDirs(cache, workspace, root, id string) (string, string, error) {
if cache == "" {
home, goCache := filepath.Join(root, "judge-home"), filepath.Join(root, "judge-go-cache")
for _, d := range []string{home, goCache} {
if err := os.MkdirAll(d, 0o700); err != nil {
return "", "", err
}
}
return home, goCache, nil
}
if withinDir(workspace, cache) {
return "", "", fmt.Errorf("the judge cache %s is inside the workspace %s, which a check's containers mount: "+
"no judge is built where a pull request could reach it", cache, workspace)
}
if !safeName.MatchString(id) {
return "", "", fmt.Errorf("the check's id %q cannot name its place in the judge cache", id)
}
home, goCache := filepath.Join(cache, "homes", id), filepath.Join(cache, "go-cache")
if err := os.RemoveAll(home); err != nil {
return "", "", err
}
for _, d := range []string{home, goCache} {
if err := os.MkdirAll(d, 0o700); err != nil {
return "", "", err
}
}
return home, goCache, nil
}
// replayPlace is where mesh-lab's replays run from and with (novox/hq issue 709), never anything a pull request can
// write: base, the directory made for this check under the judge cache's homes/ (the same path on the host and in the
// build agent), removed after it; lab, the copy of mesh-lab they run; home, their HOME and configuration; and the judge
// cache's Go build and module caches. Empty without a judge cache: the replays are then refused. hasReplays says whether
// mesh-lab held replays when it was cloned, before anything of the change ran.
type replayPlace struct {
base, lab, home, goCache, modCache string
hasReplays bool
}
// replaysNeedTheJudgeCache is the gate's error when mesh-lab holds replays and the build agent has no judge cache.
const replaysNeedTheJudgeCache = "the replays need the build agent's judge cache (mesh-catalog#297); refused rather " +
"than run from the workspace"
// refused is why the replays cannot run here, empty when they can.
func (p replayPlace) refused() string {
if p.base == "" {
return replaysNeedTheJudgeCache
}
return ""
}
// replayDirs makes the replays' place (issue 709, ADR 0358 §6). The replays' build is as trusted as a judge's: it
// compiles mesh-lab's trunk, reviewed code, and nothing of the pull request, which hands them only its catalogue, as
// data. Never the workspace: the gate runs before the replays, a pull request's own code among its containers. Without
// a judge cache there is no place: the build agent declares no other host path, and the replays are refused.
func replayDirs(cache, workspace, root, id string) (replayPlace, error) {
if cache == "" {
return replayPlace{}, nil
}
if withinDir(workspace, cache) {
return replayPlace{}, fmt.Errorf("the judge cache %s is inside the workspace %s, which a check's containers "+
"mount: no replay is run where a pull request could reach it", cache, workspace)
}
if !safeName.MatchString(id) {
return replayPlace{}, fmt.Errorf("the check's id %q cannot name its place in the judge cache", id)
}
base := filepath.Join(cache, "homes", id+"-replays")
p := replayPlace{base: base, lab: filepath.Join(base, "mesh-lab"), home: filepath.Join(base, "home"),
goCache: filepath.Join(cache, "go-cache"), modCache: filepath.Join(cache, "go-modules")}
if err := os.RemoveAll(base); err != nil {
return replayPlace{}, err
}
for _, d := range []string{p.home, p.goCache, p.modCache} {
if err := os.MkdirAll(d, 0o700); err != nil {
return replayPlace{}, err
}
}
return p, nil
}
// copyLab reads whether mesh-lab's checkout, freshly cloned and before anything of the change runs, holds replays, and
// copies it over git's own transport to where the replays run it — when there is such a place.
func (p *replayPlace) copyLab(ctx context.Context, run Runner, from string) error {
_, err := os.Stat(filepath.Join(from, "replays"))
p.hasReplays = err == nil
if p.lab == "" {
return nil
}
return copyCheckout(ctx, run, from, p.lab)
}
// replaysNotStarted says the container runtime could not start the replays' container — its own exit status 125, or
// 126 and 127 for a command it could not run — rather than that a replay failed.
func replaysNotStarted(err error) bool {
var exit *exec.ExitError
if !errors.As(err, &exit) {
return true
}
code := exit.ExitCode()
return code == 125 || code == 126 || code == 127
}
// ReplayEnv is the environment mesh-lab's replays run in (novox/hq issue 709): home as HOME and configuration, the Go
// build and module caches given, no global or system git configuration, no Go environment file, and no version
// stamped from git — as a judge is built (JudgeBuildEnv), with the modules the replays fetch.
func ReplayEnv(home, goCache, modCache string) []string {
return []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GOENV=off",
"GOCACHE=" + goCache, "GOMODCACHE=" + modCache, "GOPATH=" + filepath.Join(home, "go"),
"GOFLAGS=-buildvcs=false", "GOWORK=off"}
}
// copyCheckout copies a checkout at the commit it stands at into dir, over git's own transport — never by linking
// objects — so what is written into the source afterwards does not reach the copy.
func copyCheckout(ctx context.Context, run Runner, from, dir string) error {
commit := fullHeadAt(ctx, run, from)
if commit == "" {
return fmt.Errorf("%s names no commit", from)
}
if _, err := run(ctx, filepath.Dir(dir), "git", "clone", "--quiet", "--no-local", "--no-checkout", from, dir); err != nil {
return err
}
_, err := run(ctx, dir, "git", "checkout", "--quiet", "--detach", commit)
return err
}
// keptJudges is where a judge cache keeps the release-branch judges, empty when there is no judge cache.
func keptJudges(cache string) string {
if cache == "" {
return ""
}
return filepath.Join(cache, "judges")
}
// judgesKept is how many release-branch judges a judge cache keeps: a head moved past is not built from again.
const judgesKept = 4
// judgeKept puts the judge kept under key in the cache at bin, and says whether there was one.
func judgeKept(cache, key, bin string) bool {
if cache == "" {
return false
}
body, err := os.ReadFile(filepath.Join(cache, key, "mesh-controller"))
if err != nil {
return false
}
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
return false
}
if err := os.WriteFile(bin, body, 0o755); err != nil {
return false
}
now := time.Now()
_ = os.Chtimes(filepath.Join(cache, key), now, now)
return true
}
// keepJudge keeps a judge just built under key, and lets go of all but the judgesKept most recently used. A judge
// that cannot be kept is said and built again next time: the cache only saves time.
func keepJudge(cache, key, bin string, say func(step, format string, args ...any)) {
if cache == "" {
return
}
body, err := os.ReadFile(bin)
if err == nil {
err = os.MkdirAll(filepath.Join(cache, key), 0o755)
}
if err == nil {
kept := filepath.Join(cache, key, "mesh-controller")
if err = os.WriteFile(kept+".part", body, 0o755); err == nil {
err = os.Rename(kept+".part", kept)
}
}
if err != nil {
say("check", "the judge could not be kept for the next check, which builds it again: %v", err)
return
}
entries, err := os.ReadDir(cache)
if err != nil || len(entries) <= judgesKept {
return
}
type used struct {
name string
at time.Time
}
var all []used
for _, e := range entries {
if info, err := e.Info(); err == nil && e.IsDir() {
all = append(all, used{e.Name(), info.ModTime()})
}
}
sort.Slice(all, func(i, j int) bool { return all[i].at.After(all[j].at) })
for _, u := range all[min(judgesKept, len(all)):] {
if u.name != key {
_ = os.RemoveAll(filepath.Join(cache, u.name))
}
}
}
// fullHeadAt is the whole commit a checkout stands at, empty when it names none.
func fullHeadAt(ctx context.Context, run Runner, tree string) string {
out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
if err != nil {
return ""
}
return strings.TrimSpace(out)
}
// releaseValidator says which node-engine validator a release-branch check's judge is built with (novox/hq ADR 0358
// §2), by the rule judgeFor builds it by.
func releaseValidator(spec CheckSpec, at, commits map[string]string) string {
switch {
case spec.Judge == "self":
return "the validator the change's own controller vendors"
case spec.Judge == "validator":
return "the change's own validator"
case at["mesh-host"] == spec.Release:
return fmt.Sprintf("mesh-host's validator of %s at %s", spec.Release, commits["mesh-host"])
}
return fmt.Sprintf("the validator %s's controller vendors (mesh-host has no branch %s)", spec.Release, spec.Release)
}
// releaseWords is how a release-branch check says what judged it and what it read (novox/hq ADR 0358 §4).
func releaseWords(release, judgedBy, validator string, at, commits map[string]string) string {
var read []string
for _, dir := range sortedKeys(at) {
read = append(read, fmt.Sprintf("%s %s at %s", dir, refWords(at[dir]), commits[dir]))
}
said := fmt.Sprintf("judged for the release branch %s by its controller at %s, with %s", release, judgedBy, validator)
if len(read) > 0 {
said += "; read beside it: " + strings.Join(read, ", ")
}
return said
}
// releaseMark opens each layer's summary of a release-branch check, so a status says it first.
func releaseMark(release, judgedBy string) string {
return fmt.Sprintf("[release %s, judged by its controller %s] ", release, judgedBy)
}
// refWords is a ref as a line says it: a commit by its first eight characters, a branch whole.
func refWords(ref string) string {
if commitLike.MatchString(ref) {
return short(ref)
}
return ref
}
// commitLike is a ref that names a commit.
var commitLike = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// replaceGoFiles puts a package's Go files — never its tests — in place of another copy's.
func replaceGoFiles(from, into string) error {
old, err := filepath.Glob(filepath.Join(into, "*.go"))
@@ -1772,7 +1195,8 @@ func judge(ctx context.Context, run, plain Runner, root string, inToolchain func
say func(step, format string, args ...any)) (string, string, error) {
bin := filepath.Join(root, "bin", "mesh-controller")
build := func(dir string) error {
_, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir), nil,
_, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir),
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
"go", "build", "-o", bin, "./cmd/mesh-controller")...)
return err
}
+64 -268
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
@@ -36,17 +35,8 @@ type CheckBuilding struct {
Publish Publisher
Scratch Scratch
Held map[string]string
// Running is each artifact of the build the mesh has registered for its module, keyed "<module>/<artifact>"
// (novox/hq issue 721): what a build for the merge is compared with — never Held's newest build, which may be one
// its gate failed and the mesh put back.
Running map[string]string
Npmrc Npmrc
Seats map[string]string
// Named are the builds the build seat's verdicts of earlier checks of this pull request at this head name
// (novox/hq issue 703): a build kept under a module's key is kept again, instead of built, only when one of them
// names its key and every digest, and the build is this pull request's at this head. The record alone is never
// enough.
Named []NamedBuild
}
// CheckBuilt is one module a check built for its merge, as its verdict lists it (§1): its key and its artifacts, or
@@ -58,14 +48,6 @@ type CheckBuilt struct {
// NotBuilt says why it was not: no key, a build that failed, or a module after one of those.
NotBuilt string
Took time.Duration
// Kept is the check whose build this check kept instead of building, its key unchanged (novox/hq issue 703):
// the verdict names that build, by key and digest, as that check's. Empty when this check built it.
Kept string
// Same says every artifact came out the same as the build the mesh holds of the module (novox/hq issue 721), and
// SameAs names, per artifact, the held reference it was compared with: the walk leaves the module where it is
// while the mesh still holds that build. Empty when it differs, or could not be compared.
Same string
SameAs map[string]string
}
// skipsTheBuilds is why a check builds nothing for its merge; empty when it builds.
@@ -78,45 +60,13 @@ func skipsTheBuilds(v CheckVerdict) string {
return ""
}
// buildForTheMerge builds each module the plan moves into the scratch namespace (makeForTheMerge), and keeps what it
// built under its keys (keepForTheMerge): the order of a check that builds after it has judged.
// buildForTheMerge builds each module the plan moves, tier by tier, into the scratch namespace, and keeps what it
// built under its keys. A module standing after one not built is not built either: what it stands on in the walk
// is not what it would stand on here. tree is the check's clone and merge the merge commit it made there.
func buildForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with CheckBuilding, workspace, tree, merge string,
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) []CheckBuilt {
out, kept := makeForTheMerge(ctx, run, spec, with, workspace, tree, merge, judged, forge, log, say)
return keepForTheMerge(ctx, spec, with, out, kept, say)
}
// makeForTheMerge builds each module the plan moves, tier by tier, into the scratch namespace — **the modules of a
// tier at the same time** (novox/hq issue 716): none of them stands on another, and a tier waits only for the ones
// before it, whose builds it is handed. A module standing after one not built is not built either: what it stands on
// in the walk is not what it would stand on here. tree is the check's copy of the merge and merge the merge commit it
// made there; workspace is where each build's working copy is made, one directory per module, which the host's
// container engine sees at the same path (novox/hq issue 713). Answers what each module came to, in the plan's order,
// and the builds to keep — nothing is kept here.
func makeForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with CheckBuilding, workspace, tree, merge string,
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) ([]CheckBuilt, []CheckBuild) {
if len(spec.Builds) == 0 || with.Publish == nil || with.Scratch == nil {
return nil, nil
}
// What an earlier check left, ended before it could remove it, is not built on.
place := filepath.Join(workspace, "for-the-merge")
_ = os.RemoveAll(place)
defer os.RemoveAll(place)
// One log, said to by builds running at once: a line at a time.
var saying sync.Mutex
if log != nil {
inner := log
log = func(step, message string) {
saying.Lock()
defer saying.Unlock()
inner(step, message)
}
}
inner := say
say = func(step, format string, args ...any) {
saying.Lock()
defer saying.Unlock()
inner(step, format, args...)
return nil
}
ref := merge
if ref == "" {
@@ -126,230 +76,76 @@ func makeForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with Check
for k, v := range with.Held {
held[k] = v
}
out := make([]CheckBuilt, len(spec.Builds))
results := make([]Result, len(spec.Builds))
var out []CheckBuilt
var kept []CheckBuild
stopAt, stopWhy := -1, ""
for start := 0; start < len(spec.Builds); {
tier := spec.Builds[start].Tier
end := start
for end < len(spec.Builds) && spec.Builds[end].Tier == tier {
end++
for _, b := range spec.Builds {
began := time.Now()
if stopAt >= 0 && b.Tier > stopAt {
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: stopWhy})
continue
}
// What this tier's builds are handed: the builds of the tiers before it, read alone while they run.
handed := map[string]string{}
for k, v := range held {
handed[k] = v
if b.Skip != "" {
say("build", "%s not built for the merge: %s", b.Module, b.Skip)
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: b.Skip})
if stopAt < 0 || b.Tier < stopAt {
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
}
continue
}
var building sync.WaitGroup
for i := start; i < end; i++ {
b := spec.Builds[i]
if stopAt >= 0 && b.Tier > stopAt {
out[i] = CheckBuilt{Module: b.Module, NotBuilt: stopWhy}
continue
}
if b.Skip != "" {
say("build", "%s not built for the merge: %s", b.Module, b.Skip)
out[i] = CheckBuilt{Module: b.Module, NotBuilt: b.Skip}
continue
}
repository, at := b.Repository, b.Ref
if b.Merged {
repository, at = tree, ref
}
name := b.Module
if !safeName.MatchString(name) {
name = fmt.Sprintf("module-%d", i)
}
building.Add(1)
go func(i int, b CheckBuildSpec, repository, at, dir string) {
defer building.Done()
began := time.Now()
say("build", "for the merge: %s (tier %d)", b.Module, b.Tier)
var mirrored []string
result, err := build(ctx, run, scratchPublisher{to: with.Publish}, repository, b.Path, at, dir, handed,
with.Npmrc, forge, log, &mirrored, &Reuse{keyFirst: true, Scratch: with.Scratch, Named: with.Named,
pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, head: judged.Head},
with.Seats)
built := CheckBuilt{Module: b.Module, Key: result.ReuseKey, Took: time.Since(began)}
switch {
case errors.Is(err, errNoKey):
built.NotBuilt = err.Error()
case err != nil:
built.NotBuilt = "its build failed: " + err.Error()
case result.ReuseKey == "":
built.NotBuilt = "it has no reuse key: " + result.NoKey
}
if built.NotBuilt == "" {
// **Compared with what runs** (novox/hq issue 721): a failed comparison says nothing, and the
// module moves as before.
same, against, err := sameAsHeld(ctx, run, dir, b.Module, result.Built, with.Running)
switch {
case err != nil:
say("build", "%s could not be compared with the build the mesh holds, so it moves: %v", b.Module, err)
case same != "":
built.Same, built.SameAs = same, against
say("build", "%s %s", b.Module, same)
}
}
out[i], results[i] = built, result
}(i, b, repository, at, filepath.Join(place, name))
repository, at := b.Repository, b.Ref
if b.Merged {
repository, at = tree, ref
}
building.Wait()
for i := start; i < end; i++ {
b, built, result := spec.Builds[i], &out[i], results[i]
if built.NotBuilt != "" {
if built.Key != "" || built.Took > 0 {
say("build", "%s not built for the merge: %s", b.Module, built.NotBuilt)
}
if stopAt < 0 || b.Tier < stopAt {
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
}
continue
say("build", "for the merge: %s (tier %d)", b.Module, b.Tier)
var mirrored []string
result, err := build(ctx, run, scratchPublisher{to: with.Publish}, repository, b.Path, at,
filepath.Join(workspace, "for-the-merge"), held, with.Npmrc, forge, log, &mirrored, &Reuse{keyFirst: true},
with.Seats)
built := CheckBuilt{Module: b.Module, Key: result.ReuseKey, Took: time.Since(began)}
switch {
case errors.Is(err, errNoKey):
built.NotBuilt = err.Error()
case err != nil:
built.NotBuilt = "its build failed: " + err.Error()
case result.ReuseKey == "":
built.NotBuilt = "it has no reuse key: " + result.NoKey
}
if built.NotBuilt != "" {
say("build", "%s not built for the merge: %s", b.Module, built.NotBuilt)
out = append(out, built)
if stopAt < 0 || b.Tier < stopAt {
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
}
built.Made = result.Built
for _, m := range result.Built {
held[b.Module+"/"+m.Name] = m.Reference
}
if result.Reused != nil {
// **Kept, not built** (novox/hq issue 703): the earlier check's build, named as that check's, so the
// walk takes it as the verdict names it; this check's document names it too, so it stays while this
// check does (ADR 0350 §5).
built.Kept = result.Reused.From.Check
say("build", "%s kept for the merge under %s: %s", b.Module, twelve(result.ReuseKey[3:]),
result.Reused.From.Said())
kept = append(kept, result.Reused.From)
continue
}
say("build", "%s built for the merge under %s (%s)", b.Module, twelve(result.ReuseKey[3:]),
built.Took.Round(time.Second))
if len(result.Built) > 0 {
kept = append(kept, CheckBuild{Key: result.ReuseKey, Module: b.Module, Parts: result.ReuseParts,
Made: result.Built, Pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number},
Head: judged.Head, Trunk: judged.Trunk, Check: spec.ID, At: time.Now().UTC()})
}
continue
}
built.Made = result.Built
for _, m := range result.Built {
held[b.Module+"/"+m.Name] = m.Reference
}
say("build", "%s built for the merge under %s (%s)", b.Module, twelve(result.ReuseKey[3:]), built.Took.Round(time.Second))
out = append(out, built)
if len(result.Built) > 0 {
kept = append(kept, CheckBuild{Key: result.ReuseKey, Module: b.Module, Parts: result.ReuseParts,
Made: result.Built, Pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number},
Head: judged.Head, Trunk: judged.Trunk, Check: spec.ID, At: time.Now().UTC()})
}
start = end
}
return out, kept
}
// keepForTheMerge keeps a check's builds under their keys, its own document naming them, so the walk of its merge
// finds them; what cannot be kept is let go of, said, and each module says it — never the check's verdict.
func keepForTheMerge(ctx context.Context, spec CheckSpec, with CheckBuilding, out []CheckBuilt, kept []CheckBuild,
say func(step, format string, args ...any)) []CheckBuilt {
if len(kept) == 0 {
return out
}
if err := with.Scratch.Keep(ctx, PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, spec.ID,
time.Now().UTC(), kept); err != nil {
// Built and not findable: the walk builds, and says no check built it. Said, never the check's verdict.
say("build", "the builds for the merge could not be kept, and the walk will build: %v", err)
if err := with.Scratch.Discard(ctx, kept); err != nil {
say("build", "and what they made could not all be let go of: %v", err)
}
for i := range out {
if out[i].NotBuilt == "" {
out[i].NotBuilt = fmt.Sprintf("built, and could not be kept: %v", err)
_ = os.RemoveAll(filepath.Join(workspace, "for-the-merge"))
if len(kept) > 0 {
if err := with.Scratch.Keep(ctx, PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, spec.ID,
time.Now().UTC(), kept); err != nil {
// Built and not findable: the walk builds, and says no check built it. Said, never the check's verdict.
say("build", "the builds for the merge could not be kept, and the walk will build: %v", err)
if err := with.Scratch.Discard(ctx, kept); err != nil {
say("build", "and what they made could not all be let go of: %v", err)
}
for i := range out {
if out[i].NotBuilt == "" {
out[i].NotBuilt = fmt.Sprintf("built, and could not be kept: %v", err)
}
}
}
}
return out
}
// discardForTheMerge lets go of builds a check made beside its tests when the check did not pass (novox/hq issue 716):
// a change that fails is not merged, and nothing it built is kept. A build another check names stays (Discard).
func discardForTheMerge(ctx context.Context, with CheckBuilding, kept []CheckBuild, why string,
say func(step, format string, args ...any)) {
say("check", "%s: the %d build(s) made beside it are let go of", why, len(kept))
if len(kept) == 0 {
return
}
if err := with.Scratch.Discard(ctx, kept); err != nil {
say("build", "what they made could not all be let go of, and goes with the week's sweep: %v", err)
}
}
// besideTheTests are a check's builds for its merge running while its gate and its own check run (novox/hq issue
// 716): started once every clone is made, waited for when the check has judged, kept when it passed and let go of
// when it did not.
type besideTheTests struct {
spec CheckSpec
with CheckBuilding
cancel context.CancelFunc
done chan struct{}
place string
out []CheckBuilt
kept []CheckBuild
began time.Time
settled bool
say func(step, format string, args ...any)
}
// buildsBesideTheTests is where a check's builds run beside its tests: a directory made for this check under the judge
// cache's homes/ (ADR 0358 §6), on the host path the build agent declares — so the host's container engine mounts what
// the agent sees (issue 713) — and never mounted by a check's container, so nothing a pull request's script runs can
// change a build while it is made (issue 703). Empty, and the builds wait for the check, without a judge cache.
func buildsBesideTheTests(cache, workspace, id string) (string, error) {
if cache == "" {
return "", nil
}
if withinDir(workspace, cache) {
return "", fmt.Errorf("the judge cache %s is inside the workspace %s, which a check's containers mount: "+
"nothing is built for the merge where a pull request could reach it", cache, workspace)
}
if !safeName.MatchString(id) {
return "", fmt.Errorf("the check's id %q cannot name its place in the judge cache", id)
}
place := filepath.Join(cache, "homes", id+"-builds")
if err := os.RemoveAll(place); err != nil {
return "", err
}
if err := os.MkdirAll(place, 0o700); err != nil {
return "", err
}
return place, nil
}
// startBesideTheTests starts the builds for the merge in place, from merged, the check's copy of the merge.
func startBesideTheTests(ctx context.Context, run Runner, spec CheckSpec, place, merged string, judged CheckVerdict,
forge GitCredential, log Log, say func(step, format string, args ...any)) *besideTheTests {
building, cancel := context.WithCancel(ctx)
b := &besideTheTests{spec: spec, with: *spec.BuildWith, cancel: cancel, done: make(chan struct{}), place: place,
began: time.Now(), say: say}
say("check", "building what the merge moves (%d module(s)) beside its tests", len(spec.Builds))
go func() {
defer close(b.done)
b.out, b.kept = makeForTheMerge(building, run, spec, b.with, place, merged, judged.Merge, judged, forge, log, say)
}()
return b
}
// settle waits for the builds and keeps them when the check passed; a check that did not pass lets go of them, and
// its verdict names none.
func (b *besideTheTests) settle(ctx context.Context, verdict CheckVerdict) []CheckBuilt {
<-b.done
b.settled = true
defer os.RemoveAll(b.place)
if why := skipsTheBuilds(verdict); why != "" {
discardForTheMerge(ctx, b.with, b.kept, why, b.say)
return nil
}
built := keepForTheMerge(ctx, b.spec, b.with, b.out, b.kept, b.say)
b.say("check", "built for the merge: %d of %d module(s), beside the tests (%s)", builtCount(built), len(built),
time.Since(b.began).Round(time.Second))
return built
}
// abandon ends builds a check that ended without a verdict leaves, and lets go of what they made.
func (b *besideTheTests) abandon() {
if b.settled {
return
}
b.cancel()
<-b.done
defer os.RemoveAll(b.place)
letGo, done := context.WithTimeout(context.Background(), time.Minute)
defer done()
discardForTheMerge(letGo, b.with, b.kept, "the check ended without a verdict", b.say)
}
-50
View File
@@ -210,53 +210,3 @@ func TestACheckMergeRunsNoHook(t *testing.T) {
t.Fatalf("a hook ran when the check made its merge: %s", said)
}
}
// What a check's tests change in the workspace never reaches what it builds for the merge (novox/hq issue 703): the
// builds are made from a copy of the merge, outside the workspace, cloned before anything of the change runs.
func TestWhatTheTestsChangeInTheWorkspaceNeverReachesTheBuild(t *testing.T) {
repo, head, _ := aTrunkAndABranch(t, map[string]string{"main.go": "package main // the merge\n"},
map[string]string{"trunk-only": "t"}, map[string]string{"head-only": "h"})
workspace := t.TempDir()
tree := filepath.Join(workspace, "check", "checked")
if out, err := exec.Command("git", "clone", "--quiet", repo, tree).CombinedOutput(); err != nil {
t.Fatalf("%v\n%s", err, out)
}
dir, cloned, err := checkoutForTheBuilds(t.Context(), Command, tree, head, workspace)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
if withinDir(workspace, cloned) {
t.Fatalf("the builds' checkout %s is inside the workspace %s the tests mount", cloned, workspace)
}
// The tests rewrite the tree, its branch and every object it holds.
if err := os.WriteFile(filepath.Join(tree, "main.go"), []byte("package main // planted\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := filepath.WalkDir(filepath.Join(tree, ".git", "objects"), func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
_ = os.Chmod(path, 0o644)
return os.WriteFile(path, []byte("planted"), 0o644)
}); err != nil {
t.Fatal(err)
}
// The build clones the merge from its own checkout, as buildForTheMerge does, and reads what the merge holds.
build := filepath.Join(t.TempDir(), "source")
if out, err := exec.Command("git", "clone", "--quiet", cloned, build).CombinedOutput(); err != nil {
t.Fatalf("%v\n%s", err, out)
}
if out, err := exec.Command("git", "-C", build, "checkout", "--quiet", head).CombinedOutput(); err != nil {
t.Fatalf("%v\n%s", err, out)
}
body, err := os.ReadFile(filepath.Join(build, "main.go"))
if err != nil {
t.Fatal(err)
}
if string(body) != "package main // the merge\n" {
t.Fatalf("the build read %q: what the tests wrote reached it", body)
}
}
-870
View File
@@ -1,870 +0,0 @@
package builder
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// **A pull request into a release branch is judged by the controller of that branch** (novox/hq ADR 0358, issue
// 704): the build seat's check builds the judge from mesh-controller read at the release branch — with mesh-host's
// validator of that branch when mesh-host has one — reads every repository beside it at that branch where it has one
// and at its trunk otherwise, and names the branch, the judge's commit and each repository read with its commit.
const releaseBranch = "feat/57-config-and-provisioning"
// gitInRepo runs git in a repository made for a test.
func gitInRepo(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
// commitFiles writes files into a repository and commits them on the branch checked out.
func commitFiles(t *testing.T, dir string, files map[string]string) string {
t.Helper()
for name, body := range files {
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
gitInRepo(t, dir, "add", "-A")
gitInRepo(t, dir, "commit", "--quiet", "-m", "x")
return gitInRepo(t, dir, "rev-parse", "HEAD")
}
// aRepositoryWithARelease is a repository whose main holds mainFiles and, when releaseFiles is not nil, a release
// branch over them holding releaseFiles. It answers the repository, main's head and the release branch's head.
func aRepositoryWithARelease(t *testing.T, mainFiles, releaseFiles map[string]string) (string, string, string) {
t.Helper()
dir := t.TempDir()
gitInRepo(t, dir, "init", "--quiet", "-b", "main")
main := commitFiles(t, dir, mainFiles)
if releaseFiles == nil {
return dir, main, ""
}
gitInRepo(t, dir, "checkout", "--quiet", "-b", releaseBranch)
head := commitFiles(t, dir, releaseFiles)
gitInRepo(t, dir, "checkout", "--quiet", "main")
return dir, main, head
}
// aJudgeOf is a controller that judges as told: its gate passes, saying who it is and the word of the node-engine
// validator it vendors — the one it was built with.
func aJudgeOf(who string) map[string]string {
return map[string]string{
"go.mod": "module example.org/judge\n\ngo 1.22\n\nrequire github.com/novox/mesh-host v0.0.0\n",
"vendor/modules.txt": "# github.com/novox/mesh-host v0.0.0\n## explicit; go 1.22\ngithub.com/novox/mesh-host/validate\n",
"vendor/github.com/novox/mesh-host/validate/v.go": "package validate\n\nconst Word = \"the vendored validator\"\n",
"vendor/github.com/novox/mesh-host/internal/declaration/d.go": "package declaration\n",
"cmd/mesh-controller/main.go": `package main
import (
"fmt"
"os"
"github.com/novox/mesh-host/validate"
)
func main() {
if len(os.Args) == 2 && os.Args[1] == "merge-gate" {
fmt.Println("usage: merge-gate --facts <file> --store <postgres>")
os.Exit(2)
}
fmt.Printf("{\"verdict\":\"pass\",\"summary\":\"%s with %s\"}\n", "` + who + `", validate.Word)
}
`,
}
}
// aHostSaying is the node-engine's validator, saying a word.
func aHostSaying(word string) map[string]string {
return map[string]string{
"validate/v.go": "package validate\n\nconst Word = \"" + word + "\"\n",
"internal/declaration/d.go": "package declaration\n",
"validate/v_test.go": "package validate\n",
"internal/declaration/x_test.go": "package declaration\n",
}
}
// aRuntimeThatBuildsTheJudge puts a container runtime on PATH that runs every container's command on this machine,
// in the directory and with the environment the check gives it — the judge's build too — and a store and a bus that
// answer. It answers the file each `go` the check runs is written to.
func aRuntimeThatBuildsTheJudge(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no Go toolchain on this machine to build a judge with")
}
bus, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { bus.Close() })
go func() {
for {
c, err := bus.Accept()
if err != nil {
return
}
c.Close()
}
}()
dir := t.TempDir()
built := filepath.Join(dir, "built")
runtime := `#!/bin/sh
cmd="$1"; shift
case "$cmd" in
ps|rm|exec) exit 0 ;;
volume)
if [ -n "$VOLUMES" ]; then echo "$*" >> "$VOLUMES"; fi
exit 0 ;;
port) echo "` + bus.Addr().String() + `"; exit 0 ;;
run)
if [ -n "$RUNS" ]; then echo "$*" >> "$RUNS"; fi
workdir=""; detached=""
while [ $# -gt 0 ]; do
case "$1" in
-d) detached=1; shift ;;
--rm) shift ;;
--workdir) workdir="$2"; shift 2 ;;
--env) export "$2"; shift 2 ;;
--network|--volume|--user|--label|--name|-p|-e) shift 2 ;;
*) break ;;
esac
done
if [ -n "$detached" ]; then echo a-container; exit 0; fi
shift
if [ "$1" = go ]; then echo "GOCACHE=$GOCACHE GOENV=$GOENV $*" >> "` + built + `"; fi
if [ "$1" = go ] && [ "$2" = build ] && [ -n "$JUDGE_HOMES" ]; then echo "$HOME" >> "$JUDGE_HOMES"; fi
if [ "$1" = go ] && [ "$2" = test ] && [ -n "$REPLAYS_CANNOT_START" ]; then
echo "docker: Error response from daemon: invalid mount config for type bind" >&2; exit 125
fi
if [ "$1" = go ] && [ "$2" = test ] && [ -n "$REPLAYS" ]; then
echo "HOME=$HOME GOCACHE=$GOCACHE GOMODCACHE=$GOMODCACHE GOENV=$GOENV GOFLAGS=$GOFLAGS DIR=$workdir" >> "$REPLAYS"
fi
if [ "$1" = go ] && [ -n "$WARM_GO_CACHE" ] && [ -n "$GOCACHE" ] && [ -z "$(ls -A "$GOCACHE" 2>/dev/null)" ]; then
mkdir -p "$GOCACHE" && cp -R "$WARM_GO_CACHE/." "$GOCACHE/"
fi
cd "$workdir" && exec "$@" ;;
esac
exit 1
`
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(runtime), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("GOTOOLCHAIN", "local")
// An empty Go cache the check names is filled from a warm one before `go` runs (warm_go_cache_test.go).
t.Setenv("WARM_GO_CACHE", warmGoCache(t))
return built
}
// builds is how many times the check ran go to build a judge.
func builds(t *testing.T, file string) int {
t.Helper()
body, err := os.ReadFile(file)
if err != nil {
return 0
}
return strings.Count(string(body), "go build")
}
// theReleasedMesh is the core beside a release-branch check: a controller whose main and release branch judge
// differently, a node-engine with a validator of each, and a lab with no release branch.
func theReleasedMesh(t *testing.T) (map[string]Beside, string, string, string) {
t.Helper()
controller, _, controllerRelease := aRepositoryWithARelease(t, aJudgeOf("main's controller"),
aJudgeOf("the release branch's controller"))
host, _, hostRelease := aRepositoryWithARelease(t, aHostSaying("main's validator"),
aHostSaying("the release branch's validator"))
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{"README": "the lab"}, nil)
return map[string]Beside{
"mesh-controller": {Repository: controller, Ref: releaseBranch},
"mesh-controller-main": {Repository: controller, Ref: "main"},
"mesh-host": {Repository: host, Ref: releaseBranch, Else: "main"},
"mesh-lab": {Repository: lab, Ref: releaseBranch, Else: "main"},
}, controllerRelease, hostRelease, labMain
}
// aPullRequestInto is a repository whose pull request's head is cut from base, with a change.
func aPullRequestInto(t *testing.T, files map[string]string, base string, change map[string]string) (string, string) {
t.Helper()
repo, _, _ := aRepositoryWithARelease(t, files, map[string]string{"released": "r"})
gitInRepo(t, repo, "checkout", "--quiet", "-b", "change", base)
head := commitFiles(t, repo, change)
gitInRepo(t, repo, "checkout", "--quiet", "main")
return repo, head
}
func TestACataloguePullRequestIntoAReleaseBranchIsJudgedByThatBranchsController(t *testing.T) {
built := aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
beside, controllerRelease, hostRelease, labMain := theReleasedMesh(t)
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
cache := t.TempDir()
check := func(id string) CheckVerdict {
t.Helper()
v, err := Check(t.Context(), Command, CheckSpec{ID: id, Repository: catalogue, Ref: head, Owner: "novox",
Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch, JudgeCache: cache,
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside, Toolchain: "golang"},
t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
return v
}
v := check("check-704-catalogue")
// The release branch's controller judged, with mesh-host's validator of the release branch.
if v.Gate == nil || v.Gate.Verdict != "pass" ||
!strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the release branch's validator") {
t.Fatalf("the gate was not judged by the release branch's controller with its node-engine's validator: %+v\n%s",
v.Gate, v.Report)
}
// It names the release branch, the judge's commit, and each repository read beside it at its branch and commit.
mark := "[release " + releaseBranch + ", judged by its controller " + controllerRelease[:12] + "] "
if !strings.HasPrefix(v.Gate.Summary, mark) || !strings.HasPrefix(v.Repo.Summary, mark) {
t.Fatalf("the statuses do not name the release branch and its judge first:\n%s\n%s", v.Gate.Summary, v.Repo.Summary)
}
if v.Release != releaseBranch || v.JudgedBy != controllerRelease[:12] {
t.Fatalf("the verdict names release %q judged by %q, not %s by %s", v.Release, v.JudgedBy, releaseBranch,
controllerRelease[:12])
}
if v.BesideAt["mesh-host"] != releaseBranch || v.Beside["mesh-host"] != hostRelease[:12] {
t.Fatalf("mesh-host was read at %q (%s), not its release branch at %s", v.BesideAt["mesh-host"],
v.Beside["mesh-host"], hostRelease[:12])
}
// A repository with no release branch is read at its trunk, and says so.
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] {
t.Fatalf("the lab, with no release branch, was read at %q (%s), not main at %s", v.BesideAt["mesh-lab"],
v.Beside["mesh-lab"], labMain[:12])
}
if !strings.Contains(v.Report, "read beside it: ") || !strings.Contains(v.Report, "mesh-host "+releaseBranch+" at "+hostRelease[:12]) {
t.Fatalf("the report does not say what was read beside it:\n%s", v.Report)
}
// It says which validator judged: mesh-host's of the release branch, with its commit.
if want := "mesh-host's validator of " + releaseBranch + " at " + hostRelease[:12]; v.Validator != want ||
!strings.Contains(v.Report, want) {
t.Fatalf("the verdict says the validator was %q, not %q:\n%s", v.Validator, want, v.Report)
}
// Each repository read beside it is named with the branch and the commit read.
for dir := range beside {
if v.BesideAt[dir] == "" || v.Beside[dir] == "" ||
!strings.Contains(v.Report, dir+" "+v.BesideAt[dir]+" at "+v.Beside[dir]) {
t.Fatalf("%s is not named with its branch and commit: at %q, commit %q\n%s", dir, v.BesideAt[dir],
v.Beside[dir], v.Report)
}
}
if n := builds(t, built); n != 1 {
t.Fatalf("the judge was built %d times for one check", n)
}
// **The judge is built with the judge cache's Go cache** (ADR 0358 §6), which no check's container mounts, and no
// Go environment file.
said, _ := os.ReadFile(built)
if !strings.Contains(string(said), "GOCACHE="+filepath.Join(cache, "go-cache")+" GOENV=off go build") {
t.Fatalf("the release judge was not built with the judge cache's Go cache:\n%s", said)
}
// **Clause 5**: the head was merged onto the release branch's head, which the verdict names as its trunk commit,
// so the release branch's move puts it out of date as a trunk's move does (ADR 0323 §4, the forge's holder).
if releaseHead := gitInRepo(t, catalogue, "rev-parse", releaseBranch); v.Trunk != releaseHead[:12] {
t.Fatalf("the verdict names trunk %q, not the release branch's head %s", v.Trunk, releaseHead[:12])
}
// **Built once per release-branch head**: a second check while that head stands takes the judge kept.
v = check("check-704-catalogue-again")
if !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the release branch's validator") {
t.Fatalf("the kept judge did not judge: %+v\n%s", v.Gate, v.Report)
}
if n := builds(t, built); n != 1 {
t.Fatalf("the judge of the same release-branch head was built again: %d builds", n)
}
}
func TestAControllerPullRequestIntoAReleaseBranchReadsTheNodeEngineAtThatBranch(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
beside, _, hostRelease, _ := theReleasedMesh(t)
delete(beside, "mesh-controller")
controller, head := aPullRequestInto(t, aJudgeOf("the change's controller"), releaseBranch,
map[string]string{"cmd/x.txt": "x"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-controller", Repository: controller, Ref: head,
Owner: "novox", Repo: "mesh-controller", Number: 315, Base: releaseBranch, Release: releaseBranch,
JudgeCache: t.TempDir(), Paths: []string{"cmd/x.txt"}, Modules: []string{"mesh-controller"}, Judge: "self",
Beside: beside, Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the change's controller with the vendored validator") {
t.Fatalf("a controller change into a release branch did not judge itself: %+v\n%s", v.Gate, v.Report)
}
if v.BesideAt["mesh-host"] != releaseBranch || v.Beside["mesh-host"] != hostRelease[:12] {
t.Fatalf("mesh-host was read beside it at %q (%s), not at the release branch's %s", v.BesideAt["mesh-host"],
v.Beside["mesh-host"], hostRelease[:12])
}
if v.Release != releaseBranch || v.JudgedBy == "" || v.JudgedBy != v.Merge[:12] {
t.Fatalf("the verdict names release %q judged by %q, not its own merge %s", v.Release, v.JudgedBy, v.Merge)
}
}
func TestAPullRequestIntoTheTrunkIsJudgedAsBefore(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("main's controller"),
aJudgeOf("the release branch's controller"))
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"),
aHostSaying("the release branch's validator"))
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-trunk", Repository: catalogue, Ref: head,
Owner: "novox", Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: t.TempDir(),
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang",
Beside: map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain},
"mesh-host": {Repository: host, Ref: hostMain}}}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || v.Gate.Summary != "main's controller with the vendored validator" {
t.Fatalf("a trunk pull request was not judged by the controller beside it as before: %+v\n%s", v.Gate, v.Report)
}
if v.Release != "" || v.JudgedBy != "" || v.BesideAt != nil || strings.Contains(v.Report, "release branch") {
t.Fatalf("a trunk pull request's verdict speaks of a release branch: %q %q %v", v.Release, v.JudgedBy, v.BesideAt)
}
if v.Beside["mesh-host"] != hostMain[:12] {
t.Fatalf("mesh-host was read at %s, not the commit asked %s", v.Beside["mesh-host"], hostMain[:12])
}
}
// **Without mesh-host's release branch, the release branch's controller keeps the validator it vendors** (ADR 0358
// §2), and the verdict says so.
func TestWithoutTheNodeEnginesReleaseBranchTheJudgeKeepsItsVendoredValidator(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
beside, controllerRelease, _, _ := theReleasedMesh(t)
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"), nil)
beside["mesh-host"] = Beside{Repository: host, Ref: releaseBranch, Else: "main"}
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-vendored", Repository: catalogue, Ref: head,
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the vendored validator") {
t.Fatalf("without mesh-host's release branch the judge did not keep its vendored validator: %+v\n%s", v.Gate, v.Report)
}
if v.JudgedBy != controllerRelease[:12] || v.BesideAt["mesh-host"] != "main" || v.Beside["mesh-host"] != hostMain[:12] {
t.Fatalf("judged by %q, mesh-host read at %q (%s)", v.JudgedBy, v.BesideAt["mesh-host"], v.Beside["mesh-host"])
}
if !strings.Contains(v.Validator, "controller vendors") || !strings.Contains(v.Report, v.Validator) {
t.Fatalf("the verdict does not say the vendored validator judged: %q\n%s", v.Validator, v.Report)
}
}
// **Clause 7** (ADR 0358 §7): a release branch's own pull request into its trunk is checked as any pull request into a
// trunk — by the controller beside it as the mesh runs it, the repositories at what a trunk check reads — and its
// verdict names no release branch.
func TestAReleaseBranchsPullRequestIntoItsTrunkIsJudgedByTheControllerTheMeshRuns(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"),
aJudgeOf("the release branch's controller"))
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"),
aHostSaying("the release branch's validator"))
catalogue, _, releaseHead := aRepositoryWithARelease(t, map[string]string{
"modules/gitea/module.json": `{"module":"gitea"}`, CheckScript: "true\n"},
map[string]string{"modules/gitea/index.ts": "released"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-release-into-trunk", Repository: catalogue,
Ref: releaseHead, Owner: "novox", Repo: "mesh-catalog", Number: 300, Base: "main", JudgeCache: t.TempDir(),
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang",
Beside: map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain},
"mesh-host": {Repository: host, Ref: hostMain}}}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || v.Gate.Summary != "the controller the mesh runs with the vendored validator" {
t.Fatalf("the release branch's pull request into its trunk was not judged by the controller the mesh runs: %+v\n%s",
v.Gate, v.Report)
}
if v.Release != "" || v.JudgedBy != "" || v.BesideAt != nil || v.Validator != "" ||
strings.Contains(v.Report, "release branch") || strings.Contains(v.Gate.Summary, "[release") {
t.Fatalf("its verdict names a release branch: %q %q %v %q", v.Release, v.JudgedBy, v.BesideAt, v.Validator)
}
if v.Beside["mesh-host"] != hostMain[:12] || v.Beside["mesh-controller"] != controllerMain[:12] {
t.Fatalf("beside it were read %v, not the trunk's %s and %s", v.Beside, controllerMain[:12], hostMain[:12])
}
}
// **mesh-lab is read at its trunk, always** (ADR 0358): its replays run with the container runtime's socket, so a
// branch of the release's name in mesh-lab — which anyone may create — is never what a check reads.
func TestAReleaseBranchCheckReadsMeshLabAtItsTrunkEvenWhenItHasThatBranch(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
beside, _, _, _ := theReleasedMesh(t)
lab, labMain, labRelease := aRepositoryWithARelease(t, map[string]string{"README": "the lab"},
map[string]string{"README": "a branch anyone could push"})
beside["mesh-lab"] = Beside{Repository: lab, Ref: releaseBranch, Else: "main"}
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-lab", Repository: catalogue, Ref: head,
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] || v.Beside["mesh-lab"] == labRelease[:12] {
t.Fatalf("mesh-lab was read at %q (%s), not its trunk main at %s", v.BesideAt["mesh-lab"], v.Beside["mesh-lab"],
labMain[:12])
}
}
// aPlantedWorkspace is a workspace a pull request's own script has written into, as it can: a git configuration in its
// HOME whose fsmonitor hook says each directory git runs in, a git configuration under XDG_CONFIG_HOME doing the same,
// and a Go environment file that would hand the judge's build a tool of its own. It answers the workspace and the
// file the hook writes to.
func aPlantedWorkspace(t *testing.T) (string, string) {
t.Helper()
workspace, logs := t.TempDir(), t.TempDir()
ran := filepath.Join(logs, "hook-ran")
hook := filepath.Join(workspace, "hook.sh")
files := map[string]string{
hook: "#!/bin/sh\necho \"$PWD\" >> " + ran + "\n",
filepath.Join(workspace, ".gitconfig"): "[core]\n\tfsmonitor = " + hook + "\n",
filepath.Join(workspace, ".config", "git", "config"): "[core]\n\tfsmonitor = " + hook + "\n",
filepath.Join(workspace, ".config", "go", "env"): "GOFLAGS=-toolexec=" + hook + "\n",
}
for name, body := range files {
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
return workspace, ran
}
// judgeRanTheHook says whether the planted hook ran in a judge's tree: the controller beside the check, or the
// change's own when it judges itself.
func judgeRanTheHook(t *testing.T, ran string) string {
t.Helper()
body, _ := os.ReadFile(ran)
for _, line := range strings.Split(string(body), "\n") {
if strings.HasSuffix(line, "/check/mesh-controller") {
return string(body)
}
}
return ""
}
// **Every judge is built where no pull request reaches** (issue 482, ADR 0358 review): a git configuration with a
// hook, and a Go environment file, planted in the workspace by an earlier pull request's script, do not run during
// the release judge's build — while the pull request's own script, in the workspace, still meets them.
func TestAHookPlantedInTheWorkspaceDoesNotRunDuringTheReleaseJudgesBuild(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
homes := filepath.Join(t.TempDir(), "homes")
t.Setenv("JUDGE_HOMES", homes)
registry := aFactsRegistry(t)
workspace, ran := aPlantedWorkspace(t)
beside, _, _, _ := theReleasedMesh(t)
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "git status >/dev/null 2>&1; true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-planted", Repository: catalogue, Ref: head,
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
Toolchain: "golang"}, workspace, registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || v.Gate.Verdict != "pass" {
t.Fatalf("the check did not judge: %+v\n%s", v.Gate, v.Report)
}
if body, _ := os.ReadFile(ran); !strings.Contains(string(body), "/check/mesh-catalog") {
t.Fatalf("the planted hook never ran, so this test proves nothing: %q", body)
}
if said := judgeRanTheHook(t, ran); said != "" {
t.Fatalf("the hook planted in the workspace ran during the judge's build:\n%s", said)
}
if body, _ := os.ReadFile(homes); len(body) == 0 || strings.Contains(string(body), workspace) {
t.Fatalf("the judge was built with a HOME in the workspace: %q", body)
}
}
// The same for the judge built with a node-engine change's own validator, into a release branch (ADR 0358 §2).
func TestAHookPlantedInTheWorkspaceDoesNotRunDuringTheValidatorJudgesBuild(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
homes := filepath.Join(t.TempDir(), "homes")
t.Setenv("JUDGE_HOMES", homes)
registry := aFactsRegistry(t)
workspace, ran := aPlantedWorkspace(t)
beside, _, _, _ := theReleasedMesh(t)
delete(beside, "mesh-host")
files := aHostSaying("the release branch's validator")
files["module.json"] = `{"module":"mesh-host"}`
files[CheckScript] = "git status >/dev/null 2>&1; true\n"
host, head := aPullRequestInto(t, files, releaseBranch, aHostSaying("this change's validator"))
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-planted-validator", Repository: host, Ref: head,
Owner: "novox", Repo: "mesh-host", Number: 111, Base: releaseBranch, Release: releaseBranch, Judge: "validator",
JudgeCache: t.TempDir(), Paths: []string{"validate/v.go"}, Modules: []string{"mesh-host"}, Beside: beside,
Toolchain: "golang"}, workspace, registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with this change's validator") {
t.Fatalf("a node-engine change into a release branch was not judged by that branch's controller with its own "+
"validator: %+v\n%s", v.Gate, v.Report)
}
if body, _ := os.ReadFile(ran); !strings.Contains(string(body), "/check/mesh-host") {
t.Fatalf("the planted hook never ran, so this test proves nothing: %q", body)
}
if said := judgeRanTheHook(t, ran); said != "" {
t.Fatalf("the hook planted in the workspace ran during a judge's build:\n%s", said)
}
if body, _ := os.ReadFile(homes); strings.Count(string(body), "\n") != 2 || strings.Contains(string(body), workspace) {
t.Fatalf("the judges were not both built with a HOME outside the workspace: %q", body)
}
}
// **mesh-lab is never read at a release branch**, asked with a fallback or without (ADR 0358).
func TestTheBuildSeatNeverReadsMeshLabAtAReleaseBranch(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{"README": "the lab"},
map[string]string{"README": "a branch anyone could push"})
for _, asked := range []Beside{{Repository: lab, Ref: releaseBranch}, {Repository: lab, Ref: releaseBranch, Else: "main"}} {
beside, _, _, _ := theReleasedMesh(t)
beside["mesh-lab"] = asked
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-lab-asked", Repository: catalogue, Ref: head,
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] {
t.Fatalf("mesh-lab asked as %+v was read at %q (%s), not main at %s", asked, v.BesideAt["mesh-lab"],
v.Beside["mesh-lab"], labMain[:12])
}
}
}
// **Judges are built warm and where no pull request reaches** (ADR 0358 §6): every judge build uses the judge cache's
// one Go cache, kept between checks; its home is made fresh under the judge cache and removed after; nothing is left in
// a temporary directory of the host; and no container a check runs for the pull request mounts the judge cache.
func TestJudgesAreBuiltWarmFromAJudgeCacheNoCheckContainerMounts(t *testing.T) {
built := aRuntimeThatBuildsTheJudge(t)
// Not filled from the test's warm cache: what the first judge's build leaves in the judge cache is what is proved.
t.Setenv("WARM_GO_CACHE", "")
runs := filepath.Join(t.TempDir(), "runs")
t.Setenv("RUNS", runs)
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
registry := aFactsRegistry(t)
cache := t.TempDir()
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
beside := map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain}}
for i := range 2 {
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": fmt.Sprint(i)})
v, err := Check(t.Context(), Command, CheckSpec{ID: fmt.Sprintf("check-704-warm-%d", i), Repository: catalogue,
Ref: head, Owner: "novox", Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: cache,
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang", Beside: beside},
t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || v.Gate.Summary != "the controller the mesh runs with the vendored validator" {
t.Fatalf("check %d was not judged: %+v\n%s", i, v.Gate, v.Report)
}
if i == 0 {
if entries, _ := os.ReadDir(filepath.Join(cache, "go-cache")); len(entries) == 0 {
t.Fatal("the judge's build left nothing in the judge cache's Go cache to build the next judge warm")
}
}
}
// Both judges built with the one Go cache of the judge cache.
said, _ := os.ReadFile(built)
if n := strings.Count(string(said), "GOCACHE="+filepath.Join(cache, "go-cache")+" GOENV=off go build"); n != 2 {
t.Fatalf("%d of 2 judge builds used the judge cache's Go cache:\n%s", n, said)
}
// Nothing left behind: no home under the judge cache, nothing in the host's temporary directory.
if left, _ := os.ReadDir(filepath.Join(cache, "homes")); len(left) != 0 {
t.Fatalf("judge builds left %d home(s) under the judge cache", len(left))
}
if left, _ := os.ReadDir(tmp); len(left) != 0 {
t.Fatalf("a check left %d entr(ies) in the temporary directory, e.g. %s", len(left), left[0].Name())
}
// Only a judge build mounts the judge cache: no container the check runs for the pull request does.
body, _ := os.ReadFile(runs)
judgeBuilds := 0
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
judging := strings.Contains(line, " go build ")
if judging {
judgeBuilds++
if !strings.Contains(line, "--volume "+filepath.Join(cache, "go-cache")+":") {
t.Fatalf("a judge build did not mount the judge cache's Go cache: %s", line)
}
continue
}
if strings.Contains(line, cache) {
t.Fatalf("a container the check runs for the pull request names the judge cache: %s", line)
}
}
if judgeBuilds != 2 {
t.Fatalf("%d judge builds were run, not 2", judgeBuilds)
}
}
// Without a judge cache, a judge is built cold under the check's own root — never in a temporary directory of the
// host — and a judge cache inside the workspace is refused.
func TestWithoutAJudgeCacheAJudgeIsBuiltColdUnderTheChecksRoot(t *testing.T) {
built := aRuntimeThatBuildsTheJudge(t)
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
registry := aFactsRegistry(t)
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
beside := map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain}}
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
workspace := t.TempDir()
spec := CheckSpec{ID: "check-704-cold", Repository: catalogue, Ref: head, Owner: "novox", Repo: "mesh-catalog",
Number: 1, Base: "main", Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"},
Toolchain: "golang", Beside: beside}
v, err := Check(t.Context(), Command, spec, workspace, registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || v.Gate.Verdict != "pass" {
t.Fatalf("the check was not judged: %+v\n%s", v.Gate, v.Report)
}
said, _ := os.ReadFile(built)
if !strings.Contains(string(said), "GOCACHE="+filepath.Join(workspace, "check", "judge-go-cache")+" GOENV=off go build") {
t.Fatalf("without a judge cache the judge was not built under the check's root:\n%s", said)
}
if left, _ := os.ReadDir(tmp); len(left) != 0 {
t.Fatalf("a check left %d entr(ies) in the temporary directory", len(left))
}
spec.JudgeCache = filepath.Join(workspace, "judge-cache")
if _, err := Check(t.Context(), Command, spec, workspace, registry, GitCredential{}, nil); err == nil ||
!strings.Contains(err.Error(), "inside the workspace") {
t.Fatalf("a judge cache inside the workspace was taken: %v", err)
}
}
// aLabWithReplays is mesh-lab with replays: a Go module of its own whose one test passes, needing nothing fetched.
func aLabWithReplays(t *testing.T) (string, string) {
t.Helper()
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{
"replays/go.mod": "module example.org/replays\n\ngo 1.22\n",
"replays/replay_test.go": "package replays\n\nimport \"testing\"\n\nfunc TestAReplay(t *testing.T) {}\n",
}, nil)
return lab, labMain
}
// **mesh-lab's replays run where no pull request reaches** (novox/hq issue 709, ADR 0358 §6): they hold the container
// runtime's socket, so a Go environment file with a -toolexec hook and a Go cache and module cache planted in the
// workspace by a pull request's script are neither run nor read — the replays run from a copy of mesh-lab outside
// the workspace with a fresh home and the judge cache's caches, all removed or kept outside every check container.
func TestTheReplaysRunWhereNoPullRequestReaches(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
replays := filepath.Join(t.TempDir(), "replays")
t.Setenv("REPLAYS", replays)
registry := aFactsRegistry(t)
workspace, ran := aPlantedWorkspace(t)
for _, poisoned := range []string{filepath.Join(workspace, "go-cache", "README"),
filepath.Join(workspace, "go-modules", "cache", "download", "planted")} {
if err := os.MkdirAll(filepath.Dir(poisoned), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(poisoned, []byte("planted by a pull request"), 0o644); err != nil {
t.Fatal(err)
}
}
cache := t.TempDir()
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
lab, labMain := aLabWithReplays(t)
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-709", Repository: catalogue, Ref: head, Owner: "novox",
Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: cache, Paths: []string{"modules/gitea/index.ts"},
Modules: []string{"gitea"}, Toolchain: "golang", Beside: map[string]Beside{
"mesh-controller": {Repository: controller, Ref: controllerMain}, "mesh-lab": {Repository: lab, Ref: labMain}}},
workspace, registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || v.Gate.Verdict != "pass" || !strings.Contains(v.Report, "--- the replays") {
t.Fatalf("the replays did not run and pass: %+v\n%s", v.Gate, v.Report)
}
body, _ := os.ReadFile(replays)
said := strings.TrimSpace(string(body))
if said == "" || strings.Count(said, "\n") != 0 {
t.Fatalf("the replays ran %q, not once", said)
}
for _, want := range []string{"GOCACHE=" + filepath.Join(cache, "go-cache") + " ",
"GOMODCACHE=" + filepath.Join(cache, "go-modules") + " ", "GOENV=off ", "GOFLAGS=-buildvcs=false ",
"HOME=" + filepath.Join(cache, "homes", "check-709-replays", "home") + " ",
"DIR=" + filepath.Join(cache, "homes", "check-709-replays", "mesh-lab", "replays")} {
if !strings.Contains(said, want) {
t.Fatalf("the replays did not run with %q: %s", want, said)
}
}
if strings.Contains(said, workspace) {
t.Fatalf("the replays ran with something of the workspace: %s", said)
}
if body, _ := os.ReadFile(ran); strings.Contains(string(body), "replays") || strings.Contains(string(body), "mesh-lab") {
t.Fatalf("a hook planted in the workspace ran during the replays:\n%s", body)
}
if left, _ := os.ReadDir(filepath.Join(cache, "homes")); len(left) != 0 {
t.Fatalf("the replays left %d entr(ies) under the judge cache's homes", len(left))
}
}
// aSelfJudgingControllerThatWritesIntoTheReplays is a controller pull request whose own judge, while the gate runs it,
// writes a test into the replays beside the check — the reviewer's case for issue 709: code of the pull request's,
// run before the replays, reaching what they run.
func aSelfJudgingControllerThatWritesIntoTheReplays(t *testing.T, marker string) (string, string) {
t.Helper()
files := aJudgeOf("the change's controller")
evil := "package replays\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\nfunc TestEvil(t *testing.T) {\n\t_ = os.WriteFile(" +
fmt.Sprintf("%q", marker) + ", []byte(\"ran\"), 0o644)\n}\n"
files["cmd/mesh-controller/main.go"] = strings.Replace(files["cmd/mesh-controller/main.go"], "func main() {",
"func main() {\n\tif len(os.Args) > 2 && os.Args[1] == \"merge-gate\" {\n\t\t_ = os.WriteFile(os.Getenv(\"MESH_CHECK_BESIDE\")+"+
"\"/mesh-lab/replays/evil_test.go\", []byte("+fmt.Sprintf("%q", evil)+"), 0o644)\n\t\t_ = os.WriteFile("+
fmt.Sprintf("%q", marker+".wrote")+", nil, 0o644)\n\t}", 1)
return aPullRequestInto(t, files, "main", map[string]string{"cmd/x.txt": "x"})
}
// **The replays never read what a pull request wrote** (issue 709, reviews 12303 and 12308): with a judge cache they
// run from a copy under it, and a self-judging controller pull request writing a test into the replays beside the
// check, a planted GOFLAGS hook and a poisoned cache entry in the workspace neither run nor are read. Without one the
// replays are refused — the gate an error that says why, never a pass, never the workspace — they never start, and
// nothing is made on the host.
func TestTheReplaysNeverReadWhatAPullRequestWroteAndNeedAJudgeCache(t *testing.T) {
for _, withCache := range []bool{true, false} {
t.Run(fmt.Sprintf("judge cache %v", withCache), func(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
replays, volumes := filepath.Join(t.TempDir(), "replays"), filepath.Join(t.TempDir(), "volumes")
t.Setenv("REPLAYS", replays)
t.Setenv("VOLUMES", volumes)
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
registry := aFactsRegistry(t)
workspace, ran := aPlantedWorkspace(t)
if err := os.MkdirAll(filepath.Join(workspace, "go-cache"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(workspace, "go-cache", "README"), []byte("planted"), 0o644); err != nil {
t.Fatal(err)
}
evilRan := filepath.Join(t.TempDir(), "evil-ran")
controller, head := aSelfJudgingControllerThatWritesIntoTheReplays(t, evilRan)
lab, labMain := aLabWithReplays(t)
cache := ""
if withCache {
cache = t.TempDir()
}
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-709-self", Repository: controller, Ref: head,
Owner: "novox", Repo: "mesh-controller", Number: 330, Base: "main", Judge: "self", JudgeCache: cache,
Paths: []string{"cmd/x.txt"}, Modules: []string{"mesh-controller"}, Toolchain: "golang",
Beside: map[string]Beside{"mesh-lab": {Repository: lab, Ref: labMain}}}, workspace, registry,
GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(evilRan + ".wrote"); err != nil {
t.Fatalf("the self-judging pull request did not write into the replays beside it, so this proves nothing: %v", err)
}
if _, err := os.Stat(evilRan); err == nil {
t.Fatal("the test the pull request wrote into the replays ran with the socket")
}
if body, _ := os.ReadFile(ran); strings.Contains(string(body), "replays") || strings.Contains(string(body), "mesh-lab") {
t.Fatalf("a hook planted in the workspace ran during the replays:\n%s", body)
}
body, _ := os.ReadFile(replays)
said := strings.TrimSpace(string(body))
if vols, _ := os.ReadFile(volumes); len(vols) != 0 {
t.Fatalf("the check made a container volume: %q", vols)
}
if left, _ := os.ReadDir(tmp); len(left) != 0 {
t.Fatalf("a check left %d entr(ies) in the temporary directory", len(left))
}
if !withCache {
if v.Gate == nil || v.Gate.Verdict != "error" || !strings.Contains(v.Gate.Summary, replaysNeedTheJudgeCache) ||
v.Verdict != "error" {
t.Fatalf("without a judge cache the gate said %+v, not an error saying the replays need it", v.Gate)
}
if said != "" {
t.Fatalf("without a judge cache the replays started: %s", said)
}
return
}
if v.Gate == nil || v.Gate.Verdict != "pass" || !strings.Contains(v.Report, "--- the replays") {
t.Fatalf("the replays did not run and pass: %+v\n%s", v.Gate, v.Report)
}
base := filepath.Join(cache, "homes", "check-709-self-replays")
if said == "" || strings.Contains(said, workspace) || !strings.Contains(said, "GOENV=off ") ||
!strings.Contains(said, "DIR="+filepath.Join(base, "mesh-lab", "replays")) {
t.Fatalf("the replays did not run from the copy under %s, apart from the workspace: %q", base, said)
}
if _, err := os.Stat(base); err == nil {
t.Fatalf("the replays' place %s was left after the check", base)
}
})
}
}
// **Replays the container runtime could not start are the check's error, never the change's failure**, said in the
// build's log (review 12308); and a check whose id cannot name a place in the judge cache is refused, never given a
// shared one.
func TestReplaysThatCannotStartAreAnErrorAndAnUnsafeIDIsRefused(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
t.Setenv("REPLAYS_CANNOT_START", "1")
registry := aFactsRegistry(t)
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
lab, labMain := aLabWithReplays(t)
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
spec := CheckSpec{ID: "check-709-start", Repository: catalogue, Ref: head, Owner: "novox", Repo: "mesh-catalog",
Number: 1, Base: "main", JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"},
Modules: []string{"gitea"}, Toolchain: "golang", Beside: map[string]Beside{
"mesh-controller": {Repository: controller, Ref: controllerMain}, "mesh-lab": {Repository: lab, Ref: labMain}}}
var logged []string
v, err := Check(t.Context(), Command, spec, t.TempDir(), registry, GitCredential{}, func(step, message string) {
logged = append(logged, message)
})
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || v.Gate.Verdict != "error" || !strings.Contains(v.Gate.Summary, "could not be started") ||
!strings.Contains(v.Gate.Summary, "invalid mount config") {
t.Fatalf("replays that could not start were said %+v, not an error naming why", v.Gate)
}
if !strings.Contains(strings.Join(logged, "\n"), "the replays could not be started") {
t.Fatalf("the build's log does not say what failed:\n%s", strings.Join(logged, "\n"))
}
spec.ID = "Check 1/../../x"
if _, err := Check(t.Context(), Command, spec, t.TempDir(), registry, GitCredential{}, nil); err == nil ||
!strings.Contains(err.Error(), "cannot name its place in the judge cache") {
t.Fatalf("a check with an unsafe id was taken: %v", err)
}
}
-125
View File
@@ -1,125 +0,0 @@
package builder
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
// novox/hq issue 713: after ADR 0350 the walk of novox/mesh-controller#331 built mesh-controller, build-agent and
// route-proxy again, each "built: no check of novox/mesh-controller#331 built it". The check had built none: its
// build log said "built for the merge: 0 of 3 module(s)", mesh-controller's go bundle failing with "go.mod file not
// found". The build seat's holder runs in a container sharing only its workspace with the host, and a build's
// toolchain container is run by the host's container engine, which mounts a path as the host names it. The check
// built in a temporary directory of the holder's own (issue 703's copy), which the engine mounted as an empty one.
// aHostsEngine runs as the host's container engine does for a holder in a container: a mount of a path outside the
// workspace the holder shares with the host is an empty directory, and a Go toolchain finds no go.mod in it.
type aHostsEngine struct {
onTrunk
shared string
}
func (h aHostsEngine) run(ctx context.Context, dir, name string, args ...string) (string, error) {
if name == "docker" && len(args) > 0 && args[0] == "run" {
for i, a := range args {
if a != "--volume" || i+1 >= len(args) {
continue
}
from, _, _ := strings.Cut(args[i+1], ":")
if !withinDir(h.shared, from) {
return "", errors.New("go: go.mod file not found in current directory or any parent directory")
}
}
}
return h.onTrunk.run(ctx, dir, name, args...)
}
// aGoModule is a module of one Go bundle built from a repository's root.
func aGoModule(module string) map[string]string {
files := aSharedRepository("one", "1")
files[ManifestName] = `{"module":"` + module + `","version":"1","build":{"artifacts":[
{"name":"server","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]},
"resources":[{"id":"server","type":"process","name":"` + module + `","artifact":"server","run":["./ctl"]}]}`
return files
}
// A controller change whose walk moves mesh-controller, build-agent and route-proxy (#331's) has its check build all
// three, in the workspace the host's engine sees, and the walk of its merge takes each from the check by its key.
func TestReplay713TheWalkOfAControllerChangeTakesAllThreeFromItsCheck(t *testing.T) {
withVersion(t, "builder-1")
pull := PullRef{Owner: "novox", Repo: "mesh-controller", Number: 331}
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
repositories := map[string]string{"build-agent": "https://forge.invalid/mesh-catalog-build-agent.git",
"route-proxy": "https://forge.invalid/mesh-catalog-route-proxy.git"}
checked := func(shared string) (*recorded, aHostsEngine) {
r := &recorded{tree: "tree-of-the-merge", contents: aGoModule("mesh-controller"),
secondary: map[string]map[string]string{}}
for module, repository := range repositories {
r.secondary[repository] = aGoModule(module)
}
return r, aHostsEngine{onTrunk: onTrunk{recorded: r}, shared: shared}
}
spec := CheckSpec{ID: "build-331", Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number,
Builds: []CheckBuildSpec{{Module: "mesh-controller", Merged: true, Tier: 0},
{Module: "build-agent", Repository: repositories["build-agent"], Ref: "main", Tier: 1},
{Module: "route-proxy", Repository: repositories["route-proxy"], Ref: "main", Tier: 2}}}
judged := CheckVerdict{Head: "98e856874913", Trunk: "c975dbf7b053", Merge: "2d22279b1d07"}
passed := CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "pass"}}
// As it ran: the builds made in the holder's own temporary directory, which the host's engine does not see.
shared, outside := t.TempDir(), t.TempDir()
merged := filepath.Join(outside, "merged")
r, engine := checked(shared)
asItRan := buildForTheMerge(t.Context(), engine.run, spec, CheckBuilding{Publish: r, Scratch: newScratch(), Held: held},
outside, merged, judged.Merge, judged, GitCredential{}, nil, logging(nil))
if builtCount(asItRan) != 0 {
t.Fatalf("the engine of this test sees the holder's temporary directory: %+v", asItRan)
}
// The check as it builds now: every module its merge moves, kept under its key.
scratch := newScratch()
spec.BuildWith = &CheckBuilding{Publish: r, Scratch: scratch, Held: held}
built := forTheMerge(t.Context(), engine.run, spec, passed, shared, merged, judged, GitCredential{}, nil,
logging(nil))
if len(built) != 3 || builtCount(built) != 3 {
t.Fatalf("the check built %d of %d module(s) its merge moves: %+v", builtCount(built), len(built), built)
}
var names []NamedBuild
for _, b := range built {
n := NamedBuild{Pull: pull, Check: spec.ID, Module: b.Module, Key: b.Key, Digests: map[string]string{}}
for _, m := range b.Made {
d := m.Digest
if d == "" {
d = DigestOf(m.Reference)
}
n.Digests[m.Name] = d
}
names = append(names, n)
}
// The walk of the merge commit, tier by tier: each module taken from the check, none built.
walked := map[string]string{"mesh-controller": "https://forge.invalid/mesh-controller.git"}
for module, repository := range repositories {
walked[module] = repository
}
for _, module := range []string{"mesh-controller", "build-agent", "route-proxy"} {
w, walk := checked(shared)
w.secondary[walked["mesh-controller"]] = aGoModule("mesh-controller")
got, err := BuildReusing(t.Context(), walk.run, w, Reuse{Scratch: scratch, Pulls: []PullRef{pull}, Named: names},
walked[module], "", "main", t.TempDir(), held, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatalf("%s: %v", module, err)
}
if got.Reused == nil || got.Why != "" {
t.Fatalf("the walk built %s again: %s", module, got.Why)
}
for _, line := range w.ran {
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "go build") {
t.Fatalf("the walk compiled %s: %s", module, line)
}
}
}
}
-158
View File
@@ -1,158 +0,0 @@
package builder
import (
"context"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// novox/hq issue 716: a check built the images its merge moves one after another, and only after its merge-check.sh
// had passed — 3 to 6 minutes on a check of about 5. The modules of a tier stand on none of each other and are built
// at the same time; with a judge cache, beside the tests; a check that does not pass lets go of them.
// atOnce is a fake engine whose compiles wait until every one of a tier's has started: builds one after another never
// get there, and the first one's compile says so.
type atOnce struct {
mu sync.Mutex
r *recorded
want int
started int
all chan struct{}
shared string
}
func (a *atOnce) run(ctx context.Context, dir, name string, args ...string) (string, error) {
if name == "docker" && len(args) > 0 && args[0] == "run" && strings.Contains(strings.Join(args, " "), "go build") {
a.mu.Lock()
a.started++
if a.started == a.want {
close(a.all)
}
a.mu.Unlock()
select {
case <-a.all:
case <-time.After(5 * time.Second):
return "", errors.New("built alone: no other module of its tier was being built")
}
}
a.mu.Lock()
defer a.mu.Unlock()
return onTrunk{recorded: a.r}.run(ctx, dir, name, args...)
}
func (a *atOnce) PublishImage(ctx context.Context, localTag, repository string) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
return a.r.PublishImage(ctx, localTag, repository)
}
func (a *atOnce) PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
return a.r.PublishArchive(ctx, repository, body, digest)
}
// aLockedScratch is the scratch namespace in memory, said to by builds at once.
type aLockedScratch struct {
mu sync.Mutex
*aScratch
discarded []CheckBuild
}
func (s *aLockedScratch) Keep(ctx context.Context, p PullRef, c string, at time.Time, b []CheckBuild) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.aScratch.Keep(ctx, p, c, at, b)
}
func (s *aLockedScratch) Find(ctx context.Context, module, key string) (CheckBuild, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.aScratch.Find(ctx, module, key)
}
func (s *aLockedScratch) Discard(_ context.Context, b []CheckBuild) error {
s.mu.Lock()
defer s.mu.Unlock()
s.discarded = append(s.discarded, b...)
return nil
}
// threeAtOnce are three Go modules in one tier, each from its own repository.
func threeAtOnce(t *testing.T) (*atOnce, CheckSpec, map[string]string) {
t.Helper()
r := &recorded{tree: "tree-of-the-merge", contents: aGoModule("mesh-controller"),
secondary: map[string]map[string]string{}}
spec := CheckSpec{ID: "build-716", Owner: "novox", Repo: "mesh-controller", Number: 716,
Builds: []CheckBuildSpec{{Module: "mesh-controller", Merged: true, Tier: 0}}}
for _, module := range []string{"build-agent", "route-proxy"} {
repository := "https://forge.invalid/" + module + ".git"
r.secondary[repository] = aGoModule(module)
spec.Builds = append(spec.Builds, CheckBuildSpec{Module: module, Repository: repository, Ref: "main", Tier: 0})
}
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
return &atOnce{r: r, want: 3, all: make(chan struct{})}, spec, held
}
// The modules of one tier are built at the same time, each in a working copy of its own.
func TestReplay716TheModulesOfATierAreBuiltAtTheSameTime(t *testing.T) {
withVersion(t, "builder-1")
engine, spec, held := threeAtOnce(t)
scratch := &aLockedScratch{aScratch: newScratch()}
built := buildForTheMerge(t.Context(), engine.run, spec, CheckBuilding{Publish: engine, Scratch: scratch, Held: held},
t.TempDir(), filepath.Join(t.TempDir(), "merged"), "2d22279b1d07",
CheckVerdict{Head: "98e856874913", Trunk: "c975dbf7b053"}, GitCredential{}, nil, logging(nil))
if builtCount(built) != 3 {
t.Fatalf("the check built %d of 3 module(s) of one tier: %+v", builtCount(built), built)
}
if len(scratch.builds) != 3 {
t.Fatalf("the check kept %d build(s), not 3", len(scratch.builds))
}
}
// With a judge cache, the builds run while the tests run, where no container of the check reaches; a check that passed
// keeps them, and one that failed lets go of every one and names none.
func TestReplay716TheBuildsRunBesideTheTestsAndAFailedCheckLetsGoOfThem(t *testing.T) {
withVersion(t, "builder-1")
workspace := t.TempDir()
if _, err := buildsBesideTheTests(filepath.Join(workspace, "judge-cache"), workspace, "build-716"); err == nil {
t.Fatal("a judge cache inside the workspace was built in, where a pull request's script reaches")
}
for _, passed := range []bool{true, false} {
cache := t.TempDir()
place, err := buildsBesideTheTests(cache, workspace, "build-716")
if err != nil || !withinDir(cache, place) || withinDir(workspace, place) {
t.Fatalf("the builds' place %q (%v) is not the judge cache's, outside the workspace", place, err)
}
engine, spec, held := threeAtOnce(t)
scratch := &aLockedScratch{aScratch: newScratch()}
spec.BuildWith = &CheckBuilding{Publish: engine, Scratch: scratch, Held: held}
judged := CheckVerdict{Head: "98e856874913", Trunk: "c975dbf7b053", Merge: "2d22279b1d07"}
beside := startBesideTheTests(t.Context(), engine.run, spec, place, filepath.Join(t.TempDir(), "merged"),
judged, GitCredential{}, nil, logging(nil))
// The tests: the builds are under way before the check has a verdict.
select {
case <-engine.all:
case <-time.After(5 * time.Second):
t.Fatal("nothing was built while the tests ran")
}
verdict := CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "pass"}}
if !passed {
verdict.Repo.Verdict = "fail"
}
built := beside.settle(t.Context(), verdict)
beside.abandon()
switch {
case passed && (builtCount(built) != 3 || len(scratch.builds) != 3 || len(scratch.discarded) != 0):
t.Fatalf("a check that passed: built %+v, kept %d, let go of %d", built, len(scratch.builds),
len(scratch.discarded))
case !passed && (built != nil || len(scratch.builds) != 0 || len(scratch.discarded) != 3):
t.Fatalf("a check that failed: named %+v, kept %d, let go of %d", built, len(scratch.builds),
len(scratch.discarded))
}
}
}
+4 -12
View File
@@ -494,15 +494,11 @@ func (r Registry) Discard(ctx context.Context, builds []CheckBuild) error {
func (r Registry) letGo(ctx context.Context, checks, all map[string]checkIndex) (int, error) {
store := r.store()
stays := map[string]bool{}
// recorded are the records under a key another check still names (novox/hq issue 703): a later check that kept
// the build names it as the check's that built it, and the walk finds it by that record.
recorded := map[string]bool{}
for tag, c := range all {
if _, going := checks[tag]; going {
continue
}
for _, b := range c.Builds {
recorded[b.Module+" "+b.Key+" "+b.Check] = true
for _, m := range b.Made {
stays[catalogue.Recorded(m.Reference)] = true
}
@@ -527,10 +523,8 @@ func (r Registry) letGo(ctx context.Context, checks, all map[string]checkIndex)
}
}
// The record under the key, only while it is still this check's: a later check of the same key
// replaced it, and that one is its own check's to let go of. A build a check kept is that build's
// check's record, and goes with the last check naming it (novox/hq issue 703).
if kept, found, err := r.Find(ctx, b.Module, b.Key); err == nil && found &&
(kept.Check == c.Check || kept.Check == b.Check) && !recorded[b.Module+" "+b.Key+" "+kept.Check] {
// replaced it, and that one is its own check's to let go of.
if kept, found, err := r.Find(ctx, b.Module, b.Key); err == nil && found && kept.Check == c.Check {
if err := store.DeleteTagged(ctx, ScratchPrefix+b.Module, tagOf(b.Key)); err != nil && !errors.Is(err, artifacts.Gone) {
ok = false
failed = append(failed, fmt.Sprintf("%s's record: %v", b.Module, err))
@@ -616,8 +610,6 @@ func unnamed(named []NamedBuild, module, key string, found CheckBuild) string {
return ""
}
}
// A build of another pull request, or of a head the forge did not merge, is in no verdict the walk is handed
// (novox/hq issue 703): said with the check that made it, whose head names which head it was.
return "a build is kept under this key, by " + found.Said() + ", and no check verdict of the walk's pull requests " +
"at the head the forge merged names it with its digests: not taken"
return "a build is kept under this key, and no check verdict of the walk's pull requests names it with its " +
"digests: not taken"
}
+1 -153
View File
@@ -6,7 +6,6 @@ import (
"errors"
"net/http"
"net/http/httptest"
"reflect"
"slices"
"strconv"
"strings"
@@ -216,8 +215,7 @@ func TestARecordNoVerdictNamesIsRefusedAndBuilt(t *testing.T) {
if got.Reused != nil || len(scratch.took) > 0 || !dockerBuilt(r) {
t.Fatalf("a record no verdict names was taken: %+v, copied %v", got.Reused, scratch.took)
}
if !strings.Contains(got.Why, "no check verdict of the walk's pull requests at the head the forge merged names it") ||
!strings.Contains(got.Why, "at head abcdefabcdef") {
if !strings.Contains(got.Why, "no check verdict of the walk's pull requests names it") {
t.Fatalf("the walk says %q", got.Why)
}
})
@@ -627,153 +625,3 @@ func TestABuildTwoChecksShareGoesWithTheLastOfThem(t *testing.T) {
t.Fatal("the build stayed after the last check naming it went")
}
}
// aCheckAgain is a later check (build-2) of aPull building the module from a tree, with the builds the build seat's
// verdicts name for it (novox/hq issue 703).
func aCheckAgain(t *testing.T, scratch *aScratch, tree string, leave []NamedBuild) (CheckBuilt, *recorded) {
t.Helper()
return aCheckOf(t, scratch, aPull, "abcdefabcdef", tree, leave)
}
// aCheckOf is a later check (build-2) of a pull request at a head, the trunk moved since build-1.
func aCheckOf(t *testing.T, scratch *aScratch, pull PullRef, head, tree string, leave []NamedBuild) (CheckBuilt, *recorded) {
t.Helper()
r, run, workspace := aModuleAt(t, tree)
spec := CheckSpec{ID: "build-2", Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number,
Builds: []CheckBuildSpec{{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod",
Ref: "main"}}}
built := buildForTheMerge(t.Context(), run, spec, CheckBuilding{Publish: r, Scratch: scratch, Named: leave},
workspace, "", "", CheckVerdict{Head: head, Trunk: "654321654321"}, GitCredential{}, nil, logging(nil))
if len(built) != 1 || built[0].NotBuilt != "" || built[0].Key == "" {
t.Fatalf("the second check did not answer the module for its merge: %+v", built)
}
return built[0], r
}
// A check asked again when only the trunk moved, whose key is unchanged, builds nothing: it keeps the build an
// earlier verdict names and names it by key and digest, so the walk can take it (novox/hq issue 703).
func TestASecondCheckWhoseKeyIsUnchangedBuildsNothingAndNamesTheEarlierBuild(t *testing.T) {
withVersion(t, "builder-1")
scratch := newScratch()
first := aCheckBuilt(t, scratch, "tree-one")
again, r := aCheckAgain(t, scratch, "tree-one", named(first))
if dockerBuilt(r) || len(r.images) > 0 || len(r.archives) > 0 {
t.Fatalf("the second check built again: ran %v, published %v %v", r.ran, r.images, r.archives)
}
if again.Key != first.Key || again.Kept != "build-1" || !reflect.DeepEqual(again.Made, first.Made) {
t.Fatalf("the second check names %+v, not the first check's build %+v", again, first)
}
// The walk takes it by what the second verdict names.
leave := named(again)
leave[0].Check = again.Kept
w, run, workspace := aModuleAt(t, "tree-one")
got, err := BuildReusing(t.Context(), run, w, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: leave},
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
if err != nil || got.Reused == nil || dockerBuilt(w) {
t.Fatalf("the walk did not take the kept build: %v, why %q", err, got.Why)
}
}
// A check whose key changed builds, as before.
func TestASecondCheckWhoseKeyChangedBuilds(t *testing.T) {
withVersion(t, "builder-1")
scratch := newScratch()
first := aCheckBuilt(t, scratch, "tree-one")
again, r := aCheckAgain(t, scratch, "tree-two", named(first))
if !dockerBuilt(r) || again.Kept != "" || again.Key == first.Key {
t.Fatalf("a check whose key changed did not build: %+v", again)
}
}
// A scratch record no verdict names is never kept: the store takes writes from anything on its network, so a
// check builds rather than name a planted record (novox/hq issue 703, ADR 0350 §2).
func TestASecondCheckKeepsNoRecordAVerdictDoesNotName(t *testing.T) {
withVersion(t, "builder-1")
scratch := newScratch()
first := aCheckBuilt(t, scratch, "tree-one")
for name, leave := range map[string][]NamedBuild{
"no verdict": nil,
"another digest": func() []NamedBuild {
n := named(first)
n[0].Digests["server"] = "sha256:" + strings.Repeat("e", 64)
return n
}(),
"another key": func() []NamedBuild { n := named(first); n[0].Key = "r1:" + strings.Repeat("f", 64); return n }(),
} {
t.Run(name, func(t *testing.T) {
again, r := aCheckAgain(t, scratch, "tree-one", leave)
if !dockerBuilt(r) || again.Kept != "" {
t.Fatalf("a record no verdict names was kept: %+v", again)
}
})
}
}
// A build a later check kept stays when the check that built it goes: its artifacts and its record, which the later
// check's verdict names (novox/hq issue 703, ADR 0350 §5).
func TestABuildALaterCheckKeptStaysWhenTheCheckThatBuiltItGoes(t *testing.T) {
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
server := httptest.NewServer(m.serve())
defer server.Close()
address := strings.TrimPrefix(server.URL, "http://")
registry := Registry{Address: address}
ctx := t.Context()
now := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
body := `{"mediaType":"` + mediaManifestOCI + `","layers":[]}`
digest := digestOf([]byte(body))
m.manifests["scratch/meshboard/server@"+digest] = []byte(body)
key := "r1:" + strings.Repeat("1", 64)
b := CheckBuild{Key: key, Module: "meshboard", Pull: aPull, Check: "build-1", At: now,
Made: []catalogue.Built{{Name: "server", Kind: "image", Reference: address + "/scratch/meshboard/server@" + digest}}}
if err := registry.Keep(ctx, aPull, "build-1", now, []CheckBuild{b}); err != nil {
t.Fatal(err)
}
// Another pull request's check keeps it: its own document names the build, which stays the first check's.
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
if err := registry.Keep(ctx, other, "build-2", now, []CheckBuild{b}); err != nil {
t.Fatal(err)
}
held := func() bool { _, ok := m.manifests["scratch/meshboard/server@"+digest]; return ok }
found := func() bool { _, ok, _ := registry.Find(ctx, "meshboard", key); return ok }
if n, err := registry.Forget(ctx, aPull); err != nil || n != 1 {
t.Fatalf("forgot %d (%v)", n, err)
}
if !held() || !found() {
t.Fatalf("the build a later check names went with the check that built it: held %v, record %v", held(), found())
}
if n, err := registry.Forget(ctx, other); err != nil || n != 1 {
t.Fatalf("forgot %d (%v)", n, err)
}
if held() || found() {
t.Fatalf("the build stayed after the last check naming it went: held %v, record %v", held(), found())
}
}
// Only an earlier check of the same pull request at the same head is kept from: another head of it, or another pull
// request whose verdict names the same key and digests, builds (novox/hq issue 703).
func TestACheckKeepsNoBuildOfAnotherHeadOrPullRequest(t *testing.T) {
withVersion(t, "builder-1")
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
for name, c := range map[string]struct {
pull PullRef
head string
}{
"another head": {aPull, "999999999999"},
"another pull request": {other, "abcdefabcdef"},
} {
t.Run(name, func(t *testing.T) {
scratch := newScratch()
first := aCheckBuilt(t, scratch, "tree-one")
leave := named(first)
leave[0].Pull = c.pull // as if its own verdict named it
again, r := aCheckOf(t, scratch, c.pull, c.head, "tree-one", leave)
if !dockerBuilt(r) || again.Kept != "" {
t.Fatalf("a build of %s was kept: %+v", name, again)
}
})
}
}
-180
View File
@@ -1,180 +0,0 @@
package builder
import (
"archive/tar"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
)
// A module moves only when its program changes (novox/hq issue 721). A merge check builds every module the delivery
// plan moves; a module whose every artifact comes out the same as the build the mesh holds of it — the same files,
// the same modes and owners, the same entrypoint and environment — is said unchanged, and the walk leaves it where it
// is. **The whole image is compared, never its main binary alone**: a module whose image differs in any file moves.
// What is ignored is only what a rebuild cannot help changing: each file's time, the image's labels and its creation
// time. **A comparison that cannot be made says nothing**, and the module is built and moved as before.
// sameAsHeld says whether every artifact made is the same as the build the mesh holds of the module: the sentence
// that says so and, per artifact, the held reference it was compared with. Empty when any artifact differs, is not
// an image, has no held build, or could not be compared.
func sameAsHeld(ctx context.Context, run Runner, dir, module string, made []catalogue.Built,
held map[string]string) (string, map[string]string, error) {
if len(made) == 0 {
return "", nil, nil
}
against := map[string]string{}
for _, m := range made {
if m.Kind != "image" || m.Reference == "" {
return "", nil, nil
}
was := held[module+"/"+m.Name]
if was == "" {
return "", nil, nil
}
if digestPart(was) != "" && digestPart(was) == digestPart(m.Reference) {
against[m.Name] = was
continue
}
now, err := imageContent(ctx, run, dir, m.Reference)
if err != nil {
return "", nil, fmt.Errorf("%s's new image: %w", m.Name, err)
}
before, err := imageContent(ctx, run, dir, was)
if err != nil {
return "", nil, fmt.Errorf("%s's held image: %w", m.Name, err)
}
if now != before {
return "", nil, nil
}
against[m.Name] = was
}
var names []string
for name, ref := range against {
names = append(names, name+" "+shortRef(ref))
}
sort.Strings(names)
return "unchanged: its program is the same as running build " + strings.Join(names, ", "), against, nil
}
// digestPart is a reference's "sha256:…", empty when it is not pinned.
func digestPart(ref string) string {
if i := strings.LastIndex(ref, "@"); i >= 0 {
return ref[i+1:]
}
return ""
}
// shortRef names a reference by its digest's first twelve characters when it has one.
func shortRef(ref string) string {
if d := strings.TrimPrefix(digestPart(ref), "sha256:"); d != "" {
return twelve(d)
}
return ref
}
// imageContent is an image's content as one digest: its platform, its configuration without labels, and every file
// of its filesystem by path, type, mode, owner, link, extended attributes and bytes — never by time.
func imageContent(ctx context.Context, run Runner, dir, ref string) (string, error) {
if _, err := run(ctx, dir, "docker", "pull", "--quiet", ref); err != nil {
return "", fmt.Errorf("pulling %s: %w", ref, err)
}
config, err := run(ctx, dir, "docker", "image", "inspect", "--format",
"{{.Os}}/{{.Architecture}}/{{.Variant}} {{json .Config}}", ref)
if err != nil {
return "", fmt.Errorf("reading %s's configuration: %w", ref, err)
}
said, err := configContent(config)
if err != nil {
return "", fmt.Errorf("reading %s's configuration: %w", ref, err)
}
id, err := run(ctx, dir, "docker", "create", "--entrypoint", "/", ref)
if err != nil {
return "", fmt.Errorf("opening %s's filesystem: %w", ref, err)
}
id = strings.TrimSpace(id)
if id == "" {
return "", errors.New("the container engine named no container for " + ref)
}
defer func() { _, _ = run(context.WithoutCancel(ctx), dir, "docker", "rm", "--force", id) }()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
file := filepath.Join(dir, "content-"+twelve(id)+".tar")
defer os.Remove(file)
if _, err := run(ctx, dir, "docker", "export", "--output", file, id); err != nil {
return "", fmt.Errorf("reading %s's filesystem: %w", ref, err)
}
f, err := os.Open(file)
if err != nil {
return "", err
}
defer f.Close()
files, err := treeContent(f)
if err != nil {
return "", fmt.Errorf("reading %s's filesystem: %w", ref, err)
}
sum := sha256.Sum256([]byte(said + "\n" + files))
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// configContent is an image's platform and configuration as compared: Labels, the parent's id and the hostname the
// builder ran under are left out — the first holds the commit, the others no part of what runs.
func configContent(inspected string) (string, error) {
platform, raw, ok := strings.Cut(strings.TrimSpace(inspected), " ")
if !ok {
return "", fmt.Errorf("unexpected inspection %q", inspected)
}
var config map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &config); err != nil {
return "", err
}
for _, k := range []string{"Labels", "Image", "Hostname", "Domainname"} {
delete(config, k)
}
out, err := json.Marshal(config) // map keys are sorted
if err != nil {
return "", err
}
return platform + " " + string(out), nil
}
// treeContent is a filesystem tarball's entries, sorted by path, each with everything but its times.
func treeContent(r io.Reader) (string, error) {
tr := tar.NewReader(r)
var lines []string
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
sum := sha256.New()
if _, err := io.Copy(sum, tr); err != nil {
return "", err
}
var xattrs []string
for k, v := range h.PAXRecords {
if strings.HasPrefix(k, "SCHILY.xattr.") {
xattrs = append(xattrs, k+"="+hex.EncodeToString([]byte(v)))
}
}
sort.Strings(xattrs)
lines = append(lines, fmt.Sprintf("%q %c %o %d:%d %q %d,%d %d %x %s",
strings.TrimPrefix(h.Name, "./"), h.Typeflag, h.Mode, h.Uid, h.Gid, h.Linkname, h.Devmajor, h.Devminor,
h.Size, sum.Sum(nil), strings.Join(xattrs, ";")))
}
sort.Strings(lines)
return strings.Join(lines, "\n"), nil
}
-126
View File
@@ -1,126 +0,0 @@
package builder
import (
"archive/tar"
"bytes"
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
// aFilesystem is a filesystem tarball of files, each written at the time given.
func aFilesystem(t *testing.T, at time.Time, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, name := range []string{"usr/local/bin/mesh-builder", "etc/apk/world"} {
body, ok := files[name]
if !ok {
continue
}
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), ModTime: at,
Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// anEngine answers the container engine's commands for images by reference: each image's filesystem and labels.
func anEngine(images map[string][]byte, labels map[string]string, failing string) Runner {
created := map[string]string{}
return func(_ context.Context, _ string, name string, args ...string) (string, error) {
joined := strings.Join(args, " ")
ref := args[len(args)-1]
if failing != "" && strings.Contains(joined, failing) {
return "", fmt.Errorf("the engine refused %s", joined)
}
switch {
case args[0] == "pull", args[0] == "rm":
return "", nil
case args[0] == "image":
return fmt.Sprintf(`linux/amd64/ {"Entrypoint":["/usr/local/bin/mesh-builder"],"Labels":{"commit":%q}}`,
labels[ref]), nil
case args[0] == "create":
id := fmt.Sprintf("container%04d", len(created))
created[id] = ref
return id + "\n", nil
case args[0] == "export":
return "", os.WriteFile(args[2], images[created[ref]], 0o600)
}
return "", fmt.Errorf("unexpected %s %s", name, joined)
}
}
// TestAModuleWhoseProgramDidNotChangeIsSaidUnchanged is novox/hq issue 721: mesh-controller #335 changed one sentence
// in a package mesh-builder imports and never reaches, and build-agent moved. Built again, its image differs only in
// the files' times and its commit label, and the check says it is the same as the build the mesh holds.
func TestAModuleWhoseProgramDidNotChangeIsSaidUnchanged(t *testing.T) {
held := map[string]string{"build-agent/agent": "store/build-agent@sha256:aaaa"}
made := []catalogue.Built{{Name: "agent", Kind: "image", Reference: "scratch/build-agent@sha256:bbbb"}}
files := map[string]string{"usr/local/bin/mesh-builder": "the same program", "etc/apk/world": "docker-cli\ngit\n"}
images := map[string][]byte{
"store/build-agent@sha256:aaaa": aFilesystem(t, time.Unix(1000, 0), files),
"scratch/build-agent@sha256:bbbb": aFilesystem(t, time.Unix(2000, 0), files),
}
labels := map[string]string{"store/build-agent@sha256:aaaa": "a5500f73", "scratch/build-agent@sha256:bbbb": "c2ec0a60"}
same, against, err := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent",
made, held)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(same, "unchanged: its program is the same as running build agent") ||
against["agent"] != held["build-agent/agent"] {
t.Fatalf("an image the same but for its times and labels is not said unchanged: %q %v", same, against)
}
// **Any file differing moves it**, not only its program.
changed := map[string]string{"usr/local/bin/mesh-builder": "the same program", "etc/apk/world": "docker-cli\ngit\ncurl\n"}
images["scratch/build-agent@sha256:bbbb"] = aFilesystem(t, time.Unix(2000, 0), changed)
if same, _, err := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent", made,
held); err != nil || same != "" {
t.Fatalf("an image with another file is said unchanged: %q %v", same, err)
}
// **A comparison that fails says nothing**, and the module moves.
images["scratch/build-agent@sha256:bbbb"] = aFilesystem(t, time.Unix(2000, 0), files)
if same, _, err := sameAsHeld(context.Background(), anEngine(images, labels, "export"), t.TempDir(), "build-agent",
made, held); err == nil || same != "" {
t.Fatalf("a comparison that failed said %q, %v", same, err)
}
// No held build, or an artifact that is not an image: nothing compared.
if same, _, _ := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent", made,
nil); same != "" {
t.Fatalf("a module the mesh holds no build of is said unchanged: %q", same)
}
archive := []catalogue.Built{{Name: "agent", Kind: "archive", Reference: "scratch/build-agent@sha256:bbbb"}}
if same, _, _ := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent",
archive, held); same != "" {
t.Fatalf("an archive is said unchanged without being compared: %q", same)
}
}
// TestAnImageWithAnotherEntrypointIsNotTheSame: the configuration counts, but for its labels.
func TestAnImageWithAnotherEntrypointIsNotTheSame(t *testing.T) {
a, err := configContent(`linux/amd64/ {"Entrypoint":["/a"],"Labels":{"commit":"1"}}`)
if err != nil {
t.Fatal(err)
}
b, _ := configContent(`linux/amd64/ {"Labels":{"commit":"2"},"Entrypoint":["/a"]}`)
c, _ := configContent(`linux/amd64/ {"Entrypoint":["/b"],"Labels":{"commit":"1"}}`)
if a != b || a == c {
t.Fatalf("labels count, or the entrypoint does not: %q %q %q", a, b, c)
}
}
-80
View File
@@ -1,80 +0,0 @@
package builder
import (
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
)
// **A judge's build in a test starts from a warm Go cache** (novox/hq issue 717). The runtime these tests put on
// PATH (aRuntimeThatBuildsTheJudge) runs the check's `go build` and `go test` on this machine with the Go cache the
// check names — a fresh directory in every test — so each of them compiled the standard library from nothing: some
// ten CPU-seconds a build, under twenty tests, half of the CPU the whole suite spent. The runtime now fills an empty
// Go cache it is given from one this process warmed once, before it runs `go`; what the check names, mounts and
// keeps is unchanged, and so is what each test reads of it. A cache entry is keyed by everything the compile
// depends on, flags included, so a build with a planted -toolexec hook still misses it and still runs its hook.
//
// The warm cache lives beside the system's temporary directory as it was when the package started, never under a
// TMPDIR a test set and then asserts left empty, and is removed when the package's tests end.
var (
warmCacheBase = os.TempDir()
warmCacheOnce sync.Once
warmCacheDir string
)
func TestMain(m *testing.M) {
code := m.Run()
if warmCacheDir != "" {
_ = os.RemoveAll(warmCacheDir)
}
os.Exit(code)
}
// warmGoCache is a Go cache holding what a judge's build and the replays' `go test` compile of the standard
// library, or "" when it cannot be warmed — a test then builds cold, as before, and is only slower.
func warmGoCache(t *testing.T) string {
t.Helper()
warmCacheOnce.Do(func() {
dir, err := os.MkdirTemp(warmCacheBase, "judge-warm-go-cache-")
if err != nil {
return
}
src := filepath.Join(dir, "src")
files := map[string]string{
"go.mod": "module example.org/warm\n\ngo 1.22\n",
"main.go": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n\nfunc main() { fmt.Fprintln(os.Stdout, len(os.Args)) }\n",
"main_test.go": "package main\n\nimport \"testing\"\n\nfunc TestWarm(t *testing.T) {}\n",
}
for name, body := range files {
if err := os.MkdirAll(src, 0o755); err != nil {
return
}
if err := os.WriteFile(filepath.Join(src, name), []byte(body), 0o644); err != nil {
return
}
}
cache := filepath.Join(dir, "go-cache")
env := append(os.Environ(), "GOCACHE="+cache, "GOENV=off", "GOTOOLCHAIN=local", "GOPROXY=off", "GOWORK=off",
"GOFLAGS=-buildvcs=false", "TMPDIR="+warmCacheBase)
// As the check builds a judge (CGO off) and runs the replays (CGO as the toolchain has it).
for _, run := range [][]string{
{"env", "CGO_ENABLED=0", "go", "build", "-o", filepath.Join(dir, "judge"), "."},
{"go", "test", "-count=1", "."},
} {
cmd := exec.Command(run[0], run[1:]...)
cmd.Dir, cmd.Env = src, env
if out, err := cmd.CombinedOutput(); err != nil {
t.Logf("the warm Go cache could not be built, so judges build cold: %v\n%s", err, out)
_ = os.RemoveAll(dir)
return
}
}
warmCacheDir = dir
})
if warmCacheDir == "" {
return ""
}
return filepath.Join(warmCacheDir, "go-cache")
}
+9
View File
@@ -30,6 +30,15 @@ func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
if _, err := ParseManifest(bundled); err != nil {
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
}
// And as built: the resolved manifest registration reads keeps the bundles its build made and drops the
// build, and must be registrable just the same.
built := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
"bundles":[{"name":"tools","source":"bundles/bus/tools","digest":"sha256:00","language":"go"}]}`)
if _, err := ParseManifest(built); err != nil {
t.Fatalf("the built bus module, its bundle made, is not registrable: %v", err)
}
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
+7 -16
View File
@@ -523,12 +523,6 @@ type Manifest struct {
// (novox/hq ADR 0297). A log is its owner's alone: no other module reads it.
Logs []LogDeclaration `json:"logs,omitempty"`
// Streams are the streams this module owns on the bus (novox/hq ADR 0363): each named locally, the
// events it carries named as `emits` names them but with `*` and `**`, and bounded in age and size; the
// controller creates each. One may be restricted by a trusted setting of this module, so a consumer
// hears a part of it only where that setting's value names it.
Streams []StreamDeclaration `json:"streams,omitempty"`
// Settings are the defaults this module gives its settings (novox/hq ADR 0262): each key a file,
// a contribution or a served fact asks for as `${setting:<key>}`, its default, and why that
// default. Only a preference has one — a font size, a width, a number of workers — and a value
@@ -558,10 +552,6 @@ type Manifest struct {
// why. A person's choice through the `upgrade` verb stands over it; the bus records whatever it says.
Upgrade *UpgradePolicy `json:"upgrade,omitempty"`
// Unhealthy is how loud this module's unhealthy condition is (novox/hq ADR 0355): a warning when unsaid;
// `urgent`, with why, for a module whose failure costs people something they rely on.
Unhealthy *UnhealthyPolicy `json:"unhealthy,omitempty"`
// Reads are other modules' state this module reads and watches, each `<module>.<name>`
// (novox/hq ADR 0201). Read-only: only the owner's instances write.
Reads []string `json:"reads,omitempty"`
@@ -1654,8 +1644,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
name string
n int
}{{"consumes", len(m.Consumes)}, {"uses", len(m.Uses)}, {"invokes", len(m.Invokes)},
{"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)},
{"streams", len(m.Streams)}} {
{"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)}} {
if f.n > 0 {
said = append(said, f.name)
}
@@ -1799,8 +1788,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, StateProblems(m)...)
// And what it may call its logs, and how large it may ask them to grow (logs.go, novox/hq ADR 0297).
problems = append(problems, LogProblems(m)...)
// And the streams it owns, and who may hear them (streams.go, novox/hq ADR 0363).
problems = append(problems, StreamProblems(m)...)
// And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262).
problems = append(problems, SettingProblems(m)...)
// And what it requires optionally, and the files it delivers only while bound (novox/hq ADR 0330).
@@ -2339,7 +2326,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, m.placementProblems()...)
problems = append(problems, m.dataProblems()...)
problems = append(problems, m.upgradeProblems()...)
problems = append(problems, m.unhealthyProblems()...)
for i, r := range m.Resources {
id, _ := r["id"].(string)
@@ -2917,8 +2903,13 @@ func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string
return problems
}
// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for.
// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for: as
// written, by its build's artifacts; as built and registered, by the bundles its build made — the resolved
// manifest keeps those and drops the build (the merge gate found nats not registrable on that, review 274).
func (m Manifest) shipsABundle() bool {
if len(m.Bundles) > 0 {
return true
}
if m.Build == nil {
return false
}
-2
View File
@@ -245,8 +245,6 @@ func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, U
func CatalogueProblems(shelf Shelf) []string {
var problems []string
// A stream name has one owner (novox/hq ADR 0363).
problems = append(problems, StreamNameProblems(shelf)...)
// Who declares what, and who declared it first.
declaredBy := map[string]string{}
-479
View File
@@ -1,479 +0,0 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
)
// A module's own stream (novox/hq ADR 0363).
//
// A module declares a stream as it declares a log (ADR 0297) or a bucket (ADR 0201): named locally, the
// events it carries named locally as `emits` names them, its age and its size within the mesh's bounds.
// The controller creates it and stays the only writer of stream definitions. A stream may be restricted
// by a trusted config value of its own module: then a consumer hears a part of it only where its own
// `consumes` asks and that value names it.
// The mesh's bounds on a module's stream: its age in days and its size in MiB, and what it gets when it
// says nothing.
const (
StreamLeastDays = 1
StreamMostDays = 30
StreamDefaultDays = 7
StreamLeastMiB = 1
StreamMostMiB = 8192
StreamDefaultMiB = 1024
)
// GrantKey is the name in a grant's pattern that each key of the config value fills.
const GrantKey = "{key}"
// ReservedStreamPrefixes are the starts of the stream names the mesh derives, which no module's stream
// may take: a seat's work queue, a log, a bucket, an object store, and the dead letters.
var ReservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"}
// MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this
// package cannot import; a test keeps them agreeing.
var MeshStreamNames = []string{"CONTROL", "NODES", "ASSIGNMENTS", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS"}
// StreamDeclaration is one stream a module owns.
type StreamDeclaration struct {
Name string `json:"name"`
// Events are the events it carries, named locally; `*` is one name and `**`, last only, the rest.
Events []string `json:"events"`
// MaxAgeDays and MaxMiB bound it; zero is the default.
MaxAgeDays int `json:"max-age-days,omitempty"`
MaxMiB int `json:"max-mib,omitempty"`
// Grant restricts it, when said.
Grant *StreamGrant `json:"grant,omitempty"`
}
// StreamGrant says which trusted config value of the module grants a consumer a part of the stream, and
// which part each of its keys names.
type StreamGrant struct {
// Config is the trusted config value of the module that grants it (ADR 0348 §8): set only at the
// controller's terminal or on the operator's Approve.
Config string `json:"config"`
// Events is a pattern holding GrantKey exactly once, which each key of the config value fills.
Events string `json:"events"`
}
// UnmarshalJSON reads a stream strictly: an unknown key is refused here, not passed over.
func (s *StreamDeclaration) UnmarshalJSON(raw []byte) error {
type plain StreamDeclaration
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("a stream is {name, events, max-age-days, max-mib, grant}: %w", typedUnknown(err))
}
*s = StreamDeclaration(p)
return nil
}
// UnmarshalJSON reads a grant strictly.
func (g *StreamGrant) UnmarshalJSON(raw []byte) error {
type plain StreamGrant
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("a stream's grant is {config, events}: %w", typedUnknown(err))
}
*g = StreamGrant(p)
return nil
}
// Days is the stream's age in days, the default where none is said.
func (s StreamDeclaration) Days() int {
if s.MaxAgeDays == 0 {
return StreamDefaultDays
}
return s.MaxAgeDays
}
// Cap is the stream's size in MiB, the default where none is said.
func (s StreamDeclaration) Cap() int {
if s.MaxMiB == 0 {
return StreamDefaultMiB
}
return s.MaxMiB
}
// BusName is the stream's name on the bus: its local name in capitals, a hyphen an underscore.
func (s StreamDeclaration) BusName() string { return StreamBusName(s.Name) }
// StreamBusName is a local stream name as the bus holds it.
func StreamBusName(local string) string {
return strings.ToUpper(strings.ReplaceAll(local, "-", "_"))
}
// Carries says whether an event, or every event a pattern names, is one this stream carries: within one
// of its patterns.
func (s StreamDeclaration) Carries(pattern string) bool {
for _, p := range s.Events {
if within(pattern, p) {
return true
}
}
return false
}
// StreamProblems is what is wrong with a manifest's streams, judged on the one manifest.
func StreamProblems(m Manifest) []string {
if len(m.Streams) == 0 {
return nil
}
var problems []string
if !stateName.MatchString(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s declares a stream, and a module's name is part of its stream's subjects, which take one plain "+
"name — no dot (novox/hq ADR 0363)", m.Module))
}
seen := map[string]bool{}
terminal := TerminalKeys(m)
for i, s := range m.Streams {
switch {
case !stateName.MatchString(s.Name):
problems = append(problems, fmt.Sprintf(
"%s declares stream %q: a stream is named locally — lower-case letters, digits and hyphens "+
"(novox/hq ADR 0363)", m.Module, s.Name))
case seen[s.BusName()]:
problems = append(problems, fmt.Sprintf("%s declares stream %q twice", m.Module, s.Name))
case slices.Contains(MeshStreamNames, s.BusName()):
problems = append(problems, fmt.Sprintf(
"%s declares stream %q, which is the mesh's own stream %s (novox/hq ADR 0363)", m.Module, s.Name, s.BusName()))
default:
for _, prefix := range ReservedStreamPrefixes {
if strings.HasPrefix(s.BusName(), prefix) {
problems = append(problems, fmt.Sprintf(
"%s declares stream %q, whose name on the bus starts with %s, which the mesh derives names "+
"with (novox/hq ADR 0363)", m.Module, s.Name, prefix))
}
}
}
seen[s.BusName()] = true
if s.MaxAgeDays != 0 && (s.MaxAgeDays < StreamLeastDays || s.MaxAgeDays > StreamMostDays) {
problems = append(problems, fmt.Sprintf("%s keeps stream %q %d days; a stream keeps between %d and %d "+
"(novox/hq ADR 0363)", m.Module, s.Name, s.MaxAgeDays, StreamLeastDays, StreamMostDays))
}
if s.MaxMiB != 0 && (s.MaxMiB < StreamLeastMiB || s.MaxMiB > StreamMostMiB) {
problems = append(problems, fmt.Sprintf("%s caps stream %q at %d MiB; a stream holds between %d and %d "+
"(novox/hq ADR 0363)", m.Module, s.Name, s.MaxMiB, StreamLeastMiB, StreamMostMiB))
}
if len(s.Events) == 0 {
problems = append(problems, fmt.Sprintf("%s declares stream %q with no events", m.Module, s.Name))
}
for _, e := range s.Events {
if err := eventPattern(e, false); err != nil {
problems = append(problems, fmt.Sprintf("%s's stream %q carries %q: %v", m.Module, s.Name, e, err))
}
}
// **An event goes to one place** (ADR 0363 §2): an emitted event a stream carries, or two streams
// carrying one event, would leave the module's runtime to choose.
for _, e := range m.EmitsAll() {
if s.Carries(e) {
problems = append(problems, fmt.Sprintf(
"%s emits %q, which its stream %q carries: an event goes to EVENTS or to the module's stream, "+
"so name it in one place (novox/hq ADR 0363)", m.Module, e, s.Name))
}
}
for _, other := range m.Streams[i+1:] {
for _, a := range s.Events {
for _, b := range other.Events {
if patternsOverlap(a, b) {
problems = append(problems, fmt.Sprintf(
"%s's streams %q and %q both carry %q and %q: an event goes to one stream (novox/hq "+
"ADR 0363)", m.Module, s.Name, other.Name, a, b))
}
}
}
}
if g := s.Grant; g != nil {
if !slices.Contains(terminal, g.Config) {
problems = append(problems, fmt.Sprintf(
"%s's stream %q is granted by %q, which is not a trusted config value of the module, set only at "+
"the controller's terminal or on the operator's Approve: a grant of what a module hears is the "+
"operator's (novox/hq ADR 0348 §8, ADR 0363)", m.Module, s.Name, g.Config))
}
if strings.Count(g.Events, GrantKey) != 1 {
problems = append(problems, fmt.Sprintf(
"%s's stream %q grants %q, which must hold %s exactly once, as a name of its own (novox/hq ADR 0363)",
m.Module, s.Name, g.Events, GrantKey))
} else if err := eventPattern(g.Events, true); err != nil {
problems = append(problems, fmt.Sprintf("%s's stream %q grants %q: %v", m.Module, s.Name, g.Events, err))
}
}
}
return problems
}
// eventPattern checks one local event pattern: names, `*` for one, `**` last for the rest; with key,
// GrantKey is a name too.
func eventPattern(pattern string, key bool) error {
if pattern == "" {
return fmt.Errorf("a pattern needs a name")
}
parts := strings.Split(pattern, ".")
for i, part := range parts {
switch {
case part == TheRest:
if i != len(parts)-1 {
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest)
}
case part == OneName:
case key && part == GrantKey:
case !eventName.MatchString(part):
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
}
}
return nil
}
// StreamsHearing is every stream of a module that carries some of a consumed pattern's events, and
// whether one of them carries all of them.
func (m Manifest) StreamsHearing(pattern string) (hearing []StreamDeclaration, whole bool) {
for _, s := range m.Streams {
for _, p := range s.Events {
if patternsOverlap(pattern, p) {
hearing = append(hearing, s)
break
}
}
if s.Carries(pattern) {
whole = true
}
}
return hearing, whole
}
// within says every event pattern a names is one b names too.
func within(a, b string) bool {
x, y := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; ; i++ {
switch {
case i == len(y):
return i == len(x)
case y[i] == TheRest:
return i < len(x)
case i == len(x):
return false
case x[i] == TheRest:
return false
case y[i] == OneName:
continue
case x[i] == OneName:
return false
case x[i] != y[i]:
return false
}
}
}
// patternsOverlap says some event both patterns name.
func patternsOverlap(a, b string) bool {
x, y := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; ; i++ {
switch {
case i == len(x) && i == len(y):
return true
case i == len(x) || i == len(y):
return false
case x[i] == TheRest || y[i] == TheRest:
return true
case x[i] == OneName || y[i] == OneName || x[i] == y[i]:
continue
default:
return false
}
}
}
// StreamNameProblems are the rules about stream names no single manifest can be judged against: two
// modules declaring one name.
func StreamNameProblems(shelf Shelf) []string {
var problems []string
declaredBy := map[string]string{}
for _, module := range shelfOrder(shelf) {
for _, s := range shelf[module].Streams {
name := s.BusName()
if first, taken := declaredBy[name]; taken && first != module {
problems = append(problems, fmt.Sprintf(
"%s declares the stream %s, which %s already declares; a stream has one owner (novox/hq ADR 0363)",
module, name, first))
continue
}
declaredBy[name] = module
}
}
return problems
}
// GrantedKeys reads a restricting config value (novox/hq ADR 0363 §4): each key whose list names the
// module wherever it runs, or the module on this machine as `<machine>.<module>`. A value of another shape
// grants nothing; a key that is not one usable name grants nothing.
func GrantedKeys(value any, module, machine string) []string {
if text, isText := value.(string); isText {
// A layer may hold the object as its JSON text.
var parsed any
if json.Unmarshal([]byte(text), &parsed) != nil {
return nil
}
value = parsed
}
entries, ok := value.(map[string]any)
if !ok {
return nil
}
var out []string
for key, listed := range entries {
if !eventName.MatchString(key) {
continue
}
names, ok := listed.([]any)
if !ok {
continue
}
for _, n := range names {
s, _ := n.(string)
if s == module || (machine != "" && s == machine+"."+module) {
out = append(out, key)
break
}
}
}
slices.Sort(out)
return out
}
// Granted is a grant's pattern with its key filled.
func (g StreamGrant) Granted(key string) string { return strings.Replace(g.Events, GrantKey, key, 1) }
// narrow is the events both patterns name, as one pattern; false when they name none in common.
func narrow(a, b string) (string, bool) {
x, y := strings.Split(a, "."), strings.Split(b, ".")
var out []string
for i := 0; ; i++ {
switch {
case i == len(x) && i == len(y):
return strings.Join(out, "."), true
case i == len(x) || i == len(y):
return "", false
case x[i] == TheRest:
return strings.Join(append(out, y[i:]...), "."), true
case y[i] == TheRest:
return strings.Join(append(out, x[i:]...), "."), true
case x[i] == OneName:
out = append(out, y[i])
case y[i] == OneName || x[i] == y[i]:
out = append(out, x[i])
default:
return "", false
}
}
}
// HeardPart is what a consumer hears of one module's stream: the patterns, local, it is granted.
type HeardPart struct {
Emitter string
Stream StreamDeclaration
Patterns []string
}
// Hearing is what a consumer's `consumes` hears of the module streams the shelf declares, on one machine
// (novox/hq ADR 0363 §5): for each stream, its `consumes` narrowed to the stream's events and, for a
// restricted stream, to the parts the provider's trusted config value grants it. values answers a restricting config
// value on each machine running the provider. Answered too: whether any `consumes` names a module owning a
// stream, granted or not, and the `consumes` left for EVENTS — those naming something the provider emits
// there.
//
// Only an entry naming its emitter literally reaches a module's stream: a wildcard emitter, `**` among them,
// is EVENTS' alone, so nothing that hears every event hears a restricted stream by saying so.
func Hearing(consumer Manifest, shelf map[string]Manifest, machine string,
values func(provider, setting string) []any) (parts []HeardPart, touches bool, onEvents []string) {
byStream := map[string]*HeardPart{}
var order []string
for _, c := range consumer.Consumes {
emitter, rest, named := strings.Cut(c, ".")
provider, known := shelf[emitter]
if !named || emitter == OneName || emitter == TheRest || !known || len(provider.Streams) == 0 {
onEvents = append(onEvents, c)
continue
}
hearing, whole := provider.StreamsHearing(rest)
if len(hearing) > 0 {
touches = true
}
// Kept for EVENTS while the provider says something there it names: a stream carrying it whole, or
// a provider that says nothing of it on EVENTS, leaves it nothing to hear there.
if !whole && slices.ContainsFunc(provider.EmitsAll(), func(e string) bool { return patternsOverlap(rest, e) }) {
onEvents = append(onEvents, c)
}
for _, s := range hearing {
var granted []string
if s.Grant != nil {
for _, v := range values(emitter, s.Grant.Config) {
for _, key := range GrantedKeys(v, consumer.Module, machine) {
granted = append(granted, s.Grant.Granted(key))
}
}
}
var got []string
for _, p := range s.Events {
x, ok := narrow(rest, p)
if !ok {
continue
}
if s.Grant == nil {
got = append(got, x)
continue
}
for _, g := range granted {
if y, ok := narrow(x, g); ok {
got = append(got, y)
}
}
}
if len(got) == 0 {
continue
}
id := emitter + "/" + s.BusName()
if byStream[id] == nil {
byStream[id] = &HeardPart{Emitter: emitter, Stream: s}
order = append(order, id)
}
byStream[id].Patterns = append(byStream[id].Patterns, got...)
}
}
for _, id := range order {
h := byStream[id]
h.Patterns = uncovered(h.Patterns)
parts = append(parts, *h)
}
return parts, touches, onEvents
}
// uncovered is a list of patterns without those another names whole, and without repeats: the bus refuses
// a consumer whose filters overlap.
func uncovered(patterns []string) []string {
var out []string
for i, p := range patterns {
covered := false
for j, q := range patterns {
if i == j {
continue
}
if within(p, q) && (!within(q, p) || j < i) {
covered = true
break
}
}
if !covered {
out = append(out, p)
}
}
slices.Sort(out)
return out
}
-206
View File
@@ -1,206 +0,0 @@
package catalogue
import (
"encoding/json"
"slices"
"strings"
"testing"
)
// mailManifest is a module owning a restricted stream, as mesh-mail declares it (novox/hq ADR 0363), with
// the trusted mergeable file that takes its grants setting; extra is spliced in at the top.
func mailManifest(t *testing.T, streams, extra string) string {
t.Helper()
return `{"module":"mesh-mail","version":"1",` + extra + `"streams":` + streams + `,` +
`"resources":[{"id":"state","type":"directory","mode":"0700","place":"."},` +
`{"id":"grants","trusted":true,"type":"file","path":"${dir:state}/grants.json","mode":"0600",` +
`"merge":"json","takes-settings":["grants"],"content":"{\"grants\": {}}"}]}`
}
const mailStream = `[{"name":"mail","events":["mail.*.message.added","mail.*.message.changed",` +
`"mail.*.message.removed"],"max-age-days":7,"grant":{"config":"grants","events":"mail.{key}.**"}}]`
// A module declares a stream of its own (ADR 0363 §1): its events with a variable name (§3), its age and
// size with defaults, and a grant by a trusted setting of its own (§4).
func TestAModuleDeclaresAStreamOfItsOwn(t *testing.T) {
m, err := ParseManifest([]byte(mailManifest(t, mailStream, "")))
if err != nil {
t.Fatal(err)
}
if len(m.Streams) != 1 {
t.Fatalf("streams not read: %+v", m.Streams)
}
s := m.Streams[0]
if s.BusName() != "MAIL" || s.Days() != 7 || s.Cap() != StreamDefaultMiB || s.Grant == nil ||
s.Grant.Config != "grants" || s.Grant.Granted("mesh") != "mail.mesh.**" {
t.Fatalf("stream read as %+v", s)
}
if !s.Carries("mail.work.message.added") || s.Carries("mail.work.body") || !s.Carries("mail.*.message.removed") {
t.Fatal("the stream's patterns do not say what it carries")
}
plain, err := ParseManifest([]byte(`{"module":"feed","version":"1","streams":[{"name":"feed-items","events":["item.**"]}]}`))
if err != nil {
t.Fatalf("a stream without a grant was refused: %v", err)
}
if got := plain.Streams[0]; got.BusName() != "FEED_ITEMS" || got.Days() != StreamDefaultDays || got.Grant != nil {
t.Fatalf("defaults read as %+v", got)
}
}
// Registration refuses a stream the mesh could not hold or would leave ambiguous (ADR 0363 §1, §2, §4).
func TestAStreamIsNamedBoundedAndGrantedByATrustedConfigValue(t *testing.T) {
grants := `"grant":{"config":"grants","events":"mail.{key}.**"}`
for _, c := range []struct{ manifest, says string }{
{mailManifest(t, `[{"name":"Mail","events":["mail.**"]}]`, ""), `a stream is named locally`},
{mailManifest(t, `[{"name":"mail.x","events":["mail.**"]}]`, ""), `a stream is named locally`},
{mailManifest(t, `[{"name":"events","events":["mail.**"]}]`, ""), `the mesh's own stream EVENTS`},
{mailManifest(t, `[{"name":"log-x","events":["mail.**"]}]`, ""), `starts with LOG_`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"mail","events":["x.**"]}]`, ""), `twice`},
{mailManifest(t, `[{"name":"mail","events":[]}]`, ""), `with no events`},
{mailManifest(t, `[{"name":"mail","events":["mail.**.x"]}]`, ""), `nothing may follow it`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-age-days":31}]`, ""), `between 1 and 30`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-mib":9000}]`, ""), `between 1 and 8192`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"stream":"MAIL"}]`, ""), `{name, events`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"]}]`, `"emits":["mail.mesh.message.added"],`),
`which its stream "mail" carries`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"more","events":["mail.x.*"]}]`, ""),
`both carry`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"tags","events":"mail.{key}.**"}}]`, ""),
`is not a trusted config value`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"mail.**"}}]`, ""),
`exactly once`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"{key}.{key}"}}]`, ""),
`exactly once`},
{mailManifest(t, `[{"name":"mail","events":["mail.**"],`+grants+`,"why":1}]`, ""), `{name, events`},
{`{"module":"a.b","version":"1","streams":[{"name":"x","events":["x"]}]}`, `no dot`},
} {
_, err := ParseManifest([]byte(c.manifest))
if err == nil {
t.Errorf("%s was accepted", c.manifest)
continue
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
}
}
}
// A stream name has one owner across the catalogue (ADR 0363 §1).
func TestTwoModulesCannotDeclareOneStream(t *testing.T) {
a := Manifest{Module: "a", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"x"}}}}
b := Manifest{Module: "b", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"y"}}}}
got := strings.Join(CatalogueProblems(Shelf{"a": a, "b": b}), "; ")
if !strings.Contains(got, "b declares the stream MAIL, which a already declares") {
t.Fatalf("a second owner of MAIL was not refused: %s", got)
}
if got := StreamNameProblems(Shelf{"a": a}); len(got) != 0 {
t.Fatalf("one owner refused: %v", got)
}
}
// The setting's value names who hears each key: a module wherever it runs, or on one machine (ADR 0363 §4).
func TestARestrictingSettingNamesAModuleOrAModuleOnAMachine(t *testing.T) {
var value any
if err := json.Unmarshal([]byte(`{"mesh":["reader","person.jochen"],"work":["ace.reader"],"home":["other"],`+
`"Bad.Key":["reader"]}`), &value); err != nil {
t.Fatal(err)
}
if got := GrantedKeys(value, "reader", "ace"); !slices.Equal(got, []string{"mesh", "work"}) {
t.Fatalf("on ace reader is granted %v", got)
}
if got := GrantedKeys(value, "reader", "shanks"); !slices.Equal(got, []string{"mesh"}) {
t.Fatalf("on shanks reader is granted %v", got)
}
if got := GrantedKeys(`{"mesh":["reader"]}`, "reader", "ace"); !slices.Equal(got, []string{"mesh"}) {
t.Fatalf("a value held as its JSON text grants %v", got)
}
if got := GrantedKeys([]any{"reader"}, "reader", "ace"); len(got) != 0 {
t.Fatalf("a value of another shape granted %v", got)
}
}
// A consumer hears a restricted stream only where its consumes asks and the setting grants it, and only
// by naming the emitter (ADR 0363 §5); what a stream carries whole leaves EVENTS.
func TestAConsumerHearsOnlyWhatItAsksAndIsGranted(t *testing.T) {
provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`)))
if err != nil {
t.Fatal(err)
}
shelf := map[string]Manifest{"mesh-mail": provider}
var value any
_ = json.Unmarshal([]byte(`{"mesh":["reader"],"work":["shanks.reader"]}`), &value)
values := func(module, setting string) []any {
if module == "mesh-mail" && setting == "grants" {
return []any{value}
}
return nil
}
reader := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.**", "mesh-mail.account.added", "billing.paid"}}
parts, touches, onEvents := Hearing(reader, shelf, "ace", values)
if !touches || len(parts) != 1 || parts[0].Stream.BusName() != "MAIL" {
t.Fatalf("heard %+v", parts)
}
want := []string{"mail.mesh.message.added", "mail.mesh.message.changed", "mail.mesh.message.removed"}
if !slices.Equal(parts[0].Patterns, want) {
t.Fatalf("on ace reader hears %v, want %v", parts[0].Patterns, want)
}
if !slices.Equal(onEvents, []string{"mesh-mail.account.added", "billing.paid"}) {
t.Fatalf("left on EVENTS: %v", onEvents)
}
parts, _, _ = Hearing(reader, shelf, "shanks", values)
if len(parts) != 1 || len(parts[0].Patterns) != 6 {
t.Fatalf("on shanks reader hears %+v", parts)
}
narrow := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.*.message.added"}}
parts, _, _ = Hearing(narrow, shelf, "ace", values)
if len(parts) != 1 || !slices.Equal(parts[0].Patterns, []string{"mail.mesh.message.added"}) {
t.Fatalf("a narrower consumes heard %+v", parts)
}
stranger := Manifest{Module: "stranger", Consumes: []string{"mesh-mail.mail.**"}}
parts, touches, onEvents = Hearing(stranger, shelf, "ace", values)
if len(parts) != 0 || !touches || len(onEvents) != 0 {
t.Fatalf("a module not granted heard %+v (touches %v, on EVENTS %v)", parts, touches, onEvents)
}
everything := Manifest{Module: "audit", Consumes: []string{"**", "*.mail.**"}}
parts, touches, _ = Hearing(everything, shelf, "ace", values)
if len(parts) != 0 || touches {
t.Fatalf("a wildcard emitter reached a module's stream: %+v", parts)
}
parts, _, _ = Hearing(reader, shelf, "ace", func(string, string) []any { return nil })
if len(parts) != 0 {
t.Fatalf("with no setting reader heard %+v", parts)
}
}
// A stream without a grant is heard by whoever asks, within its patterns.
func TestAnUnrestrictedStreamIsHeardByWhoeverAsks(t *testing.T) {
feed := Manifest{Module: "feed", Streams: []StreamDeclaration{{Name: "items", Events: []string{"item.*.added", "item.*.gone"}}}}
reader := Manifest{Module: "reader", Consumes: []string{"feed.item.**", "feed.item.x.added"}}
parts, touches, onEvents := Hearing(reader, map[string]Manifest{"feed": feed}, "ace", nil)
if !touches || len(onEvents) != 0 || len(parts) != 1 ||
!slices.Equal(parts[0].Patterns, []string{"item.*.added", "item.*.gone"}) {
t.Fatalf("heard %+v, on EVENTS %v", parts, onEvents)
}
}
func TestNarrowingTwoPatterns(t *testing.T) {
for _, c := range []struct{ a, b, want string }{
{"mail.**", "mail.*.message.added", "mail.*.message.added"},
{"mail.*.message.added", "mail.mesh.**", "mail.mesh.message.added"},
{"a.*", "*.b", "a.b"},
{"**", "a.**", "a.**"},
{"a.b", "a.c", ""},
{"a", "a.**", ""},
} {
got, ok := narrow(c.a, c.b)
if (c.want == "") == ok || got != c.want {
t.Errorf("narrow(%s, %s) = %q, %v; want %q", c.a, c.b, got, ok, c.want)
}
}
if got := uncovered([]string{"a.**", "a.b", "a.b", "c"}); !slices.Equal(got, []string{"a.**", "c"}) {
t.Fatalf("uncovered: %v", got)
}
}
-55
View File
@@ -1,55 +0,0 @@
package catalogue
import (
"fmt"
"strings"
)
// How loud a module's unhealthy condition is (novox/hq ADR 0355, extending ADR 0240).
//
// **A module may declare its unhealthy urgent.** A module whose failure costs people something they rely on
// (mail refused or stuck for everyone with a mailbox) says so with `unhealthy`, and its condition
// `module.<module>.<machine>.unhealthy` is raised urgent instead of a warning. **Urgent needs a why**, as an
// `upgrade` policy other than roll does: a person reading the catalogue sees what is lost while it is broken.
// The severities a module may declare for its unhealthy condition.
const (
// UnhealthyUrgent raises the module's unhealthy condition urgent, on every machine it runs on.
UnhealthyUrgent = "urgent"
// UnhealthyWarning is what a module's unhealthy condition is without the field, said out loud.
UnhealthyWarning = "warning"
)
// UnhealthyPolicy is what a module says about how loud its unhealthy condition is: `unhealthy` in its manifest
// (novox/hq ADR 0355).
type UnhealthyPolicy struct {
Severity string `json:"severity"`
// Why names what people lose while the module is broken. It is required: a module that says nothing of
// why it is loud is refused.
Why string `json:"why,omitempty"`
}
// unhealthyProblems refuses an `unhealthy` without a why, and any severity but urgent or warning (novox/hq ADR
// 0355 §2).
func (m Manifest) unhealthyProblems() []string {
if m.Unhealthy == nil {
return nil
}
var problems []string
switch m.Unhealthy.Severity {
case UnhealthyUrgent, UnhealthyWarning:
default:
problems = append(problems, fmt.Sprintf("unhealthy is %q or %q, not %q", UnhealthyUrgent, UnhealthyWarning,
m.Unhealthy.Severity))
}
if strings.TrimSpace(m.Unhealthy.Why) == "" {
problems = append(problems, "unhealthy says why: a module that declares how loud its failure is names "+
"what people lose while it is broken")
}
return problems
}
// UnhealthyIsUrgent is whether the module declares its unhealthy condition urgent (novox/hq ADR 0355 §1).
func (m Manifest) UnhealthyIsUrgent() bool {
return m.Unhealthy != nil && m.Unhealthy.Severity == UnhealthyUrgent
}
-53
View File
@@ -1,53 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// A module may declare its unhealthy urgent, with why; the module check refuses it without why and with any
// severity but urgent or warning (novox/hq ADR 0355).
func TestAnUnhealthyDeclaredUrgentIsUrgent(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"mail","version":"1",` +
`"unhealthy":{"severity":"urgent","why":"mail is refused or stuck for everyone with a mailbox"}}`))
if err != nil {
t.Fatalf("a manifest declaring its unhealthy urgent with why was refused: %v", err)
}
if !m.UnhealthyIsUrgent() {
t.Errorf("a manifest declaring its unhealthy urgent reads as not urgent: %+v", m.Unhealthy)
}
}
func TestAManifestWithoutUnhealthyIsNotUrgent(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"notes","version":"1"}`))
if err != nil {
t.Fatal(err)
}
if m.UnhealthyIsUrgent() {
t.Errorf("a manifest saying nothing of its unhealthy reads as urgent")
}
warning := Manifest{Module: "notes", Unhealthy: &UnhealthyPolicy{Severity: UnhealthyWarning, Why: "notes wait"}}
if warning.UnhealthyIsUrgent() || len(warning.unhealthyProblems()) != 0 {
t.Errorf("a warning said with why reads as urgent or is refused: %v", warning.unhealthyProblems())
}
}
func TestAnUnhealthyWithoutWhyIsRefused(t *testing.T) {
for _, why := range []string{"", " "} {
if problems := (Manifest{Module: "mail", Unhealthy: &UnhealthyPolicy{Severity: UnhealthyUrgent, Why: why}}).unhealthyProblems(); len(problems) == 0 {
t.Errorf("urgent with why %q was accepted", why)
}
}
if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"urgent"}}`)); err == nil ||
!strings.Contains(err.Error(), "unhealthy says why") {
t.Errorf("a manifest declaring its unhealthy urgent without why parsed: %v", err)
}
}
func TestAnUnhealthySeverityOtherThanUrgentOrWarningIsRefused(t *testing.T) {
if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"loud","why":"mail stops"}}`)); err == nil ||
!strings.Contains(err.Error(), `not "loud"`) {
t.Errorf("a manifest declaring its unhealthy loud parsed: %v", err)
}
}
-46
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -302,51 +301,6 @@ func (i *Inventory) Held(ctx context.Context) (map[string]string, error) {
return held, nil
}
// RegisteredHeld is Held narrowed to the build each module has registered (novox/hq issue 721): an artifact is kept
// only when its reference's digest is in the manifest the module table holds for it, which is what its machines are
// sent. After a gate failed and the mesh put the earlier build back (RestoreModule), the newest successful build is
// not what runs, and its artifacts are left out here, so nothing is compared with them.
func (i *Inventory) RegisteredHeld(ctx context.Context) (map[string]string, error) {
held, err := i.Held(ctx)
if err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx, `select name, coalesce(manifest::text, '') from module`)
if err != nil {
return nil, err
}
defer rows.Close()
registered := map[string]string{}
for rows.Next() {
var name, manifest string
if err := rows.Scan(&name, &manifest); err != nil {
return nil, err
}
registered[name] = manifest
}
if err := rows.Err(); err != nil {
return nil, err
}
return registeredOnly(held, registered), nil
}
// registeredOnly keeps of held each "<module>/<artifact>" whose reference's digest the module's registered manifest
// names.
func registeredOnly(held, registered map[string]string) map[string]string {
out := map[string]string{}
for key, ref := range held {
module, _, ok := strings.Cut(key, "/")
at := strings.LastIndex(ref, "@")
if !ok || at < 0 || len(ref) <= at+1 {
continue
}
if strings.Contains(registered[module], ref[at+1:]) {
out[key] = ref
}
}
return out
}
// BuiltAgainst is what each module's newest successful build stood on, as recorded — the build
// edges (ADR 0009). A module whose last build recorded no bases is absent, which is also what a
// module standing on nothing looks like: an edge the mesh has not derived is not an edge.
+1 -115
View File
@@ -61,23 +61,12 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{},
Interchangeable: map[string]bool{}}
assigned := map[string][]string{}
for _, n := range nodes {
out.Nodes = append(out.Nodes, n.Name)
modules, err := i.Assigned(ctx, n.Name)
if err != nil {
return broker.Records{}, fmt.Errorf("cannot read what %s runs: %w", n.Name, err)
}
assigned[n.Name] = modules
}
// What each restricting config value says on each machine running its module (novox/hq ADR 0363 §5): read
// once, for every consumer composed below.
grants, err := i.streamGrantValues(ctx, declared, nodes, assigned)
if err != nil {
return broker.Records{}, err
}
for _, n := range nodes {
out.Nodes = append(out.Nodes, n.Name)
modules := assigned[n.Name]
for _, module := range modules {
m, known := declared[module]
if !known {
@@ -100,9 +89,6 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
d.KeyedReads = append(d.KeyedReads, broker.KeyedRead{Read: sr.Read, Key: sr.Key})
}
d.Holds = heldHere(d.Holds, holdings, n.Name, module)
// And the module streams it hears on this machine, as granted (novox/hq ADR 0363 §5): what a
// stream carries whole leaves EVENTS' consumes.
d.Hears, d.HearsAStream, d.Consumes = hearsOf(m, declared, n.Name, grants, d.Consumes)
out.Assigned[n.Name] = append(out.Assigned[n.Name], d)
if m.Instances == catalogue.InstancesInterchangeable {
out.Interchangeable[m.Module] = true
@@ -183,8 +169,6 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
Reads: m.Reads,
// And the logs it keeps (novox/hq ADR 0297).
Logs: logsOf(m),
// And the streams it owns (novox/hq ADR 0363).
Streams: streamsOf(m),
// And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them.
Checks: catalogue.HealthChecks(m),
// And whether it runs as an account of its own (novox/hq ADR 0259 §8).
@@ -378,101 +362,3 @@ func (i *Inventory) DeclaredTrafficSeats(ctx context.Context) ([]broker.Seat, er
}
return out, nil
}
// streamsOf is the streams a module owns, as the bus holds them (novox/hq ADR 0363).
func streamsOf(m catalogue.Manifest) []broker.ModuleStream {
var out []broker.ModuleStream
for _, s := range m.Streams {
events := make([]string, 0, len(s.Events))
for _, e := range s.Events {
events = append(events, broker.WirePattern(e))
}
out = append(out, broker.ModuleStream{Module: m.Module, Name: s.BusName(), Events: events,
MaxAgeDays: s.Days(), MaxMiB: s.Cap(), Restricted: s.Grant != nil})
}
return out
}
// DeclaredStreams is every module stream the catalogue declares, registered modules assigned or not: a
// stream exists from registration, as a log does (novox/hq ADR 0363 §1).
func (i *Inventory) DeclaredStreams(ctx context.Context) ([]broker.ModuleStream, error) {
declared, err := i.Catalogue(ctx)
if err != nil {
return nil, fmt.Errorf("cannot read the catalogue: %w", err)
}
var out []broker.ModuleStream
for _, m := range declared {
out = append(out, streamsOf(m)...)
}
return out, nil
}
// grantValues is each restricting config value on each machine running its module, by `<module>/<key>`.
type grantValues map[string][]any
// streamGrantValues reads, for every module owning a restricted stream, the config value that restricts it on every
// machine the module runs on: the mesh's layer under that machine's, the machine's winning (novox/hq ADR 0363 §5).
func (i *Inventory) streamGrantValues(ctx context.Context, declared map[string]catalogue.Manifest,
nodes []Node, assigned map[string][]string) (grantValues, error) {
out := grantValues{}
for _, n := range nodes {
for _, module := range assigned[n.Name] {
m, known := declared[module]
if !known {
continue
}
var settings []string
for _, s := range m.Streams {
if s.Grant != nil && !slices.Contains(settings, s.Grant.Config) {
settings = append(settings, s.Grant.Config)
}
}
if len(settings) == 0 {
continue
}
layers, err := i.SettingsFor(ctx, n.Name, module)
if err != nil {
return nil, fmt.Errorf("cannot read %s's settings on %s, which grant who hears its stream: %w",
module, n.Name, err)
}
for _, setting := range settings {
if v, set := settingValue(layers, setting); set {
out[module+"/"+setting] = append(out[module+"/"+setting], v)
}
}
}
}
return out, nil
}
// settingValue is one key's value over a machine's layers, the last that sets it winning. Pure.
func settingValue(layers []catalogue.Layer, key string) (any, bool) {
var value any
set := false
for _, l := range layers {
if v, ok := l.Values[key]; ok {
value, set = v, true
}
}
return value, set
}
// hearsOf is what a module hears of the module streams on one machine, and the consumes left for EVENTS
// (novox/hq ADR 0363 §5). Pure.
func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node string, grants grantValues,
consumes []string) ([]broker.Heard, bool, []string) {
reading := m
reading.Consumes = consumes
parts, touches, onEvents := catalogue.Hearing(reading, declared, node, func(provider, setting string) []any {
return grants[provider+"/"+setting]
})
var out []broker.Heard
for _, p := range parts {
h := broker.Heard{Stream: p.Stream.BusName(), Emitter: p.Emitter}
for _, pattern := range p.Patterns {
h.Filters = append(h.Filters, broker.ModuleStreamSubject(p.Emitter, broker.WirePattern(pattern)))
}
out = append(out, h)
}
return out, touches, onEvents
}
-3
View File
@@ -104,9 +104,6 @@ type PlanMerge struct {
Number int `json:"number,omitempty"`
Title string `json:"title,omitempty"`
Moves []string `json:"moves,omitempty"`
// Head is the pull request's head commit the forge merged (novox/hq issue 703): the walk takes only a build a
// check of that head made. Empty where the announcement said none, and then it takes none.
Head string `json:"head,omitempty"`
// Merged is when the forge made the merge and Heard when the controller heard it (novox/hq ADR 0282): where
// its delivery time starts. Zero for a merge named before they were kept.
Merged time.Time `json:"merged,omitzero"`
@@ -1,72 +0,0 @@
package inventory
import (
"encoding/json"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
// TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack is novox/hq issue 721's review: the newest build of a module
// failed its gate and the mesh put the build before it back. Held names the newest; what runs, and what a merge's build
// is compared with, is the build put back — so a merge whose bytes equal the failed build is never said unchanged.
func TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
manifestOf := func(image string) catalogue.Manifest {
return catalogue.Manifest{Module: "one", Version: "1", Resources: []map[string]any{
{"id": "app", "type": "container", "name": "one", "image": image}}}
}
record := func(id, image string, asked time.Time) Build {
t.Helper()
raw, _ := json.Marshal(manifestOf(image))
b := Build{ID: id, Module: "one", Commit: id + "aaaaaaaa", Repository: "novox/one", Manifest: raw,
Asked: asked, At: asked, Made: []Artifact{{Name: "one", Kind: "image", Reference: image}}}
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, manifestOf(image), Source{Repository: "novox/one", BuiltFrom: b.Commit,
Asked: asked}); err != nil {
t.Fatal(err)
}
return b
}
x := "store/one@sha256:" + "1111111111111111111111111111111111111111111111111111111111111111"
y := "store/one@sha256:" + "2222222222222222222222222222222222222222222222222222222222222222"
before := record("x", x, time.Now().Add(-2*time.Hour))
failed := record("y", y, time.Now().Add(-time.Minute))
if err := inv.RecordGate(ctx, GateVerdict{Build: failed.ID, Module: "one", Commit: failed.Commit,
Machines: []string{"anchor"}, Verdict: GateFailed}); err != nil {
t.Fatal(err)
}
if err := inv.RestoreModule(ctx, before); err != nil {
t.Fatal(err)
}
held, err := inv.Held(ctx)
if err != nil {
t.Fatal(err)
}
running, err := inv.RegisteredHeld(ctx)
if err != nil {
t.Fatal(err)
}
if running["one/one"] == y {
t.Fatalf("what runs is read as the build whose gate failed (%s); Held says %s", y, held["one/one"])
}
if got := running["one/one"]; got != "" && got != x {
t.Fatalf("what runs is read as %s, want the build put back %s or nothing", got, x)
}
}
// TestOnlyArtifactsTheRegisteredManifestNamesAreRunning: registeredOnly keeps a held artifact only when its digest is
// in its module's registered manifest.
func TestOnlyArtifactsTheRegisteredManifestNamesAreRunning(t *testing.T) {
held := map[string]string{"one/app": "store/one@sha256:aaaa", "two/app": "store/two@sha256:bbbb", "three/app": "nodigest"}
registered := map[string]string{"one": `{"resources":[{"image":"store/one@sha256:aaaa"}]}`,
"two": `{"resources":[{"image":"store/two@sha256:cccc"}]}`, "three": "nodigest"}
got := registeredOnly(held, registered)
if len(got) != 1 || got["one/app"] == "" {
t.Fatalf("running reads %v, want one/app alone", got)
}
}
-45
View File
@@ -1,45 +0,0 @@
package inventory
import (
"encoding/json"
"slices"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// A machine's layer wins over the mesh's for the setting that restricts a stream (novox/hq ADR 0363 §5).
func TestARestrictingSettingIsReadMachineOverMesh(t *testing.T) {
layers := []catalogue.Layer{
{From: catalogue.MeshWideLayer, Values: map[string]any{"grants": "mesh", "other": 1}},
{From: "novox", Values: map[string]any{"grants": "novox"}},
}
if v, set := settingValue(layers, "grants"); !set || v != "novox" {
t.Fatalf("read %v, %v", v, set)
}
if _, set := settingValue(layers[:1], "absent"); set {
t.Fatal("a key no layer sets read as set")
}
}
// What a consumer hears is composed into the bus's subjects, and what a stream carries leaves EVENTS.
func TestWhatAModuleHearsIsComposedOntoTheStreamsSubjects(t *testing.T) {
provider := catalogue.Manifest{Module: "mesh-mail", Streams: []catalogue.StreamDeclaration{{Name: "mail",
Events: []string{"mail.*.message.added"}, Grant: &catalogue.StreamGrant{Config: "grants", Events: "mail.{key}.**"}}}}
var value any
_ = json.Unmarshal([]byte(`{"mesh":["reader"]}`), &value)
grants := grantValues{"mesh-mail/grants": {value}}
reader := catalogue.Manifest{Module: "reader"}
hears, touches, onEvents := hearsOf(reader, map[string]catalogue.Manifest{"mesh-mail": provider}, "ace", grants,
[]string{"mesh-mail.mail.**", "billing.paid"})
if !touches || len(hears) != 1 || hears[0].Stream != "MAIL" ||
!slices.Equal(hears[0].Filters, []string{"mesh.stream.mesh-mail.event.mail.mesh.message.added"}) {
t.Fatalf("heard %+v", hears)
}
if !slices.Equal(onEvents, []string{"billing.paid"}) {
t.Fatalf("left on EVENTS: %v", onEvents)
}
if got := streamsOf(provider); len(got) != 1 || got[0].Name != "MAIL" || got[0].MaxAgeDays != 7 || !got[0].Restricted {
t.Fatalf("the provider's stream: %+v", got)
}
}
+1 -2
View File
@@ -397,9 +397,8 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
return append(out, point{phase: PhaseApply, tier: -1, said: "the rest's sends are not kept for a walk made before ADR 0282"})
}
if len(lastSends) == 0 {
// novox/hq issue 720: since ADR 0360 no gate reads a walk; its one machine's report is its run.
return append(out, point{phase: PhaseApply, tier: -1, none: true,
said: "no machine beyond the first: its one machine's report was its run"})
said: "no machine beyond the first: the first-node gate's readings were its run"})
}
if applied == nil {
return append(out, point{phase: PhaseApply, tier: -1, said: "the rest's reports were not read"})
+1 -1
View File
@@ -89,7 +89,7 @@ func TestARecordedWalksPhasesAddUpToItsTotal(t *testing.T) {
if got := phase(w, PhaseWord, -1); got.State != PhaseNone {
t.Errorf("a walk that waits for no word says its word phase %s", got.State)
}
if got := phase(w, PhaseApply, -1); got.State != PhaseNone || !strings.Contains(got.Said, "no machine beyond the first: its one machine's report was its run") {
if got := phase(w, PhaseApply, -1); got.State != PhaseNone || !strings.Contains(got.Said, "no machine beyond the first") {
t.Errorf("one machine only: apply %s (%s)", got.State, got.Said)
}
}
+34 -169
View File
@@ -10,23 +10,13 @@
// controller A (epoch 41) ──renew──renew──╳ (renewal refused)──► stops acting, exits
// controller B ──wait──────────────take (epoch 57)──► acts
//
// **A renewal that fails is not a lost lease** (novox/hq issue 477, the operator's standing rule). A
// renewal the bus did not answer in time — a stall of its JetStream API — says nothing about another
// holder: none can take the key while it lives. So the instance keeps the lease it holds until the key's
// own age has passed without a successful renewal, and tries again every renewal interval:
//
// renewed ──╳ (no answer)──╳──────────── renewed ──► acts on, as before
// 0 ½ age: no new act starts (the gate opens again)
// renewed ──╳──────╳──────╳──────╳ age ──► lost, exits
//
// **Two ways an instance loses the lease, said and final**: Lost closes and the process exits, so its
// service manager restarts it as a candidate. Another holder seen — the renewal refused because the
// key moved, and the key naming somebody else or gone — is a loss at once. The key's age passed with no
// successful renewal is a loss too, because past it the key may be somebody else's. And Epoch, the gate
// every act passes, answers an error from the moment the last renewal is older than half the key's age
// (or its age less a margin, if that is sooner) — **long before** another instance could have taken it,
// whatever the renewing goroutine is doing — so a send in flight while the bus stalls is refused by the
// clock, not by a goroutine that may be stuck, and the gate opens again with the next renewal made.
// **Three ways an instance stops acting, all at once.** A renewal refused — somebody else wrote the key
// — is a loss, said and final: Lost closes and the process exits, so its service manager restarts it as
// a candidate. A renewal that cannot be made in time is the same, because past its time the key may be
// somebody else's. And Epoch, the gate every act passes, answers an error from the moment the last
// renewal is older than the key's age less a margin — **before** another instance could have taken
// it, whatever the renewing goroutine is doing — so a send in flight at the moment of loss is refused
// by the clock, not by a goroutine that may be stuck.
package lease
import (
@@ -105,11 +95,6 @@ type Options struct {
// Health is asked at every take and renewal for the holder's word about itself (witness.go); nil
// writes none.
Health func() *Health
// Delayed is told of every renewal that failed without losing the lease — the bus did not answer
// in time — with why, when the lease was last renewed, and when it is lost unless a renewal is made
// first. It is where a bus stall is said as one (novox/hq issue 478); nil tells nobody. It is called
// on the renewing goroutine and must not block.
Delayed func(why error, renewed, lostBy time.Time)
}
// Lease is one instance's hold, or its wait for one.
@@ -125,22 +110,14 @@ type Lease struct {
held bool
epoch uint64
revision uint64
// validUntil is when this instance stops starting acts unless it renews: the moment before its last
// renewal was sent, plus half the key's age (or the age less the margin, if sooner).
// validUntil is when this instance stops acting unless it renews: the moment before its last
// renewal was sent, plus the key's age, less the margin.
validUntil time.Time
// keptUntil is when this instance loses the lease unless it renews: the moment before its last
// renewal was sent, plus the key's age (novox/hq issue 477).
keptUntil time.Time
// renewed is when this instance last took or renewed the key.
renewed time.Time
// failing is how many renewals failed since the last one made, and failingSince when the first did.
failing int
failingSince time.Time
lostWhy error
lost chan struct{}
taken time.Time
// update writes the key at a revision: the bucket's own, or a test's bus that does not answer.
update func(ctx context.Context, key string, value []byte, last uint64) (uint64, error)
lostWhy error
lost chan struct{}
taken time.Time
}
// Open is a lease over the bucket, read for its age. The bucket is the caller's to assert.
@@ -184,7 +161,7 @@ func Open(ctx context.Context, js jetstream.JetStream, bucket string, o Options)
return nil, fmt.Errorf("a lease renewed every %s with a margin of %s does not fit a key that lives %s",
o.RenewEvery, o.Margin, status.TTL())
}
return &Lease{kv: kv, stream: stream, ttl: status.TTL(), o: o, lost: make(chan struct{}), update: kv.Update}, nil
return &Lease{kv: kv, stream: stream, ttl: status.TTL(), o: o, lost: make(chan struct{})}, nil
}
// TTL is how long the key lives unrenewed.
@@ -307,8 +284,8 @@ func (l *Lease) TryTake(ctx context.Context) (uint64, error) {
"back to be taken again", revision, floor)
}
l.mu.Lock()
l.held, l.epoch, l.revision, l.taken = true, revision, revision, now
l.renewedAt(anchor)
l.held, l.epoch, l.revision, l.taken, l.renewed = true, revision, revision, now, anchor
l.validUntil = anchor.Add(l.ttl - l.o.Margin)
l.mu.Unlock()
l.o.Say("took the controller lease at epoch %d (%s)", revision, h.Instance)
return revision, nil
@@ -354,20 +331,8 @@ func (l *Lease) aboveTheFloor(ctx context.Context) error {
return nil
}
// renewedAt records a take or renewal sent at anchor: the gate open until half the key's age (or its age
// less the margin, if sooner), the lease kept until its age. Called with mu held.
func (l *Lease) renewedAt(anchor time.Time) {
open := l.ttl / 2
if l.ttl-l.o.Margin < open {
open = l.ttl - l.o.Margin
}
l.renewed, l.validUntil, l.keptUntil = anchor, anchor.Add(open), anchor.Add(l.ttl)
l.failing, l.failingSince = 0, time.Time{}
}
// Epoch is the gate every act passes: this instance's epoch while it holds the lease and its last
// renewal is in time, and an error otherwise — not held, lost, or a renewal overdue by half the key's
// age, which a renewal made opens again (novox/hq issue 477).
// renewal is in time, and an error otherwise — not held, lost, or a renewal overdue.
func (l *Lease) Epoch() (uint64, error) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -377,9 +342,8 @@ func (l *Lease) Epoch() (uint64, error) {
case !l.held:
return 0, ErrNotHeld
case l.o.Now().After(l.validUntil):
return 0, fmt.Errorf("%w in time: no renewal was made since %s, so no new act starts until one is "+
"(the lease is kept until %s)", ErrNotHeld, l.renewed.UTC().Format(time.RFC3339),
l.keptUntil.UTC().Format(time.RFC3339))
return 0, fmt.Errorf("%w in time: its last renewal was due by %s", ErrNotHeld,
l.validUntil.UTC().Format(time.RFC3339))
}
return l.epoch, nil
}
@@ -407,41 +371,25 @@ func (l *Lease) LostWhy() error {
return l.lostWhy
}
// Keep renews the lease until ctx ends or it is lost. A renewal that fails without losing it is tried
// again at the next interval, and the lease is lost when its key's age passes with none made, even while
// a renewal hangs (novox/hq issue 477).
// Keep renews the lease until ctx ends or a renewal fails; on a failure it is lost.
func (l *Lease) Keep(ctx context.Context) {
tick := time.NewTicker(l.o.RenewEvery)
defer tick.Stop()
for {
var expire <-chan time.Time
l.mu.Lock()
if l.failing > 0 {
expire = time.After(l.keptUntil.Sub(l.o.Now()))
}
l.mu.Unlock()
select {
case <-ctx.Done():
return
case <-tick.C:
case <-expire:
}
_ = l.Renew(ctx)
if ctx.Err() != nil || l.LostWhy() != nil {
if err := l.Renew(ctx); err != nil {
return // lost, said by lose; or stopping
}
}
}
// ErrDelayed is a renewal that failed without losing the lease: the bus did not answer in time, and the
// key's age has not passed since the last renewal made (novox/hq issue 477).
var ErrDelayed = errors.New("the lease's renewal is delayed")
// Renew writes the key once more at the revision this instance last wrote. Another holder seen — the
// write refused and the key naming somebody else, or gone — is the lease lost at once. A renewal that
// cannot be made — the bus does not answer in time — is not (novox/hq issue 477): it answers ErrDelayed,
// the lease is kept, and the gate closes only once half the key's age has passed without a renewal made.
// The lease is lost when the whole age has: past it the key may be somebody else's.
// Renew writes the key once more at the revision this instance last wrote. A refusal or a failure is
// the lease lost: past this renewal's time the key may be somebody else's, and an instance that is not
// sure it holds the lease does not act on it.
func (l *Lease) Renew(ctx context.Context) error {
l.mu.Lock()
if !l.held || l.lostWhy != nil {
@@ -451,15 +399,12 @@ func (l *Lease) Renew(ctx context.Context) error {
}
return ErrNotHeld
}
revision, epoch, taken, keptUntil := l.revision, l.epoch, l.taken, l.keptUntil
revision, epoch, taken := l.revision, l.epoch, l.taken
l.mu.Unlock()
anchor := l.o.Now()
if !anchor.Before(keptUntil) {
return l.lose(l.overdue(nil))
}
h := l.o.Holder
h.Epoch, h.Taken = epoch, taken
anchor := l.o.Now()
h.Renewed = anchor
if l.o.Health != nil {
h.Health = l.o.Health()
@@ -468,107 +413,27 @@ func (l *Lease) Renew(ctx context.Context) error {
if err != nil {
return l.lose(err)
}
// One renewal waits no longer than its own bound, nor past the moment the lease is lost without it.
within := l.o.RenewWithin
if left := keptUntil.Sub(anchor); left < within {
within = left
}
renewing, cancel := context.WithTimeout(ctx, within)
renewing, cancel := context.WithTimeout(ctx, l.o.RenewWithin)
defer cancel()
next, err := l.update(renewing, Key, value, revision)
if err != nil && ctx.Err() == nil && (errors.Is(err, jetstream.ErrKeyExists) || isWrongLast(err)) {
// The key moved. A renewal whose answer the stall swallowed may have been written all the same:
// the key still naming this instance at this epoch is that one, and is renewed at the revision it
// has. Anything else is another holder — or the key gone, free for one — and the lease lost.
var why error
next, why, err = l.renewMoved(renewing, epoch, value, revision, err)
if why != nil {
return l.lose(why)
}
}
next, err := l.kv.Update(renewing, Key, value, revision)
if err != nil {
if ctx.Err() != nil {
// Stopping, not losing: Release gives it back.
return ctx.Err()
}
return l.delayed(fmt.Errorf("the renewal could not be made: %w", err))
why := fmt.Errorf("the renewal at revision %d was refused: %w", revision, err)
if !errors.Is(err, jetstream.ErrKeyExists) && !isWrongLast(err) {
why = fmt.Errorf("the renewal could not be made: %w", err)
}
return l.lose(why)
}
l.mu.Lock()
failed, since := l.failing, l.failingSince
l.revision = next
l.renewedAt(anchor)
l.revision, l.renewed = next, anchor
l.validUntil = anchor.Add(l.ttl - l.o.Margin)
l.mu.Unlock()
if failed > 0 {
l.o.Say("renewed the controller lease (epoch %d) after %d renewal(s) that could not be made since %s: "+
"this controller acts again", epoch, failed, since.UTC().Format(time.RFC3339))
}
return nil
}
// renewMoved is a renewal the bus refused because the key moved: the renewal made at the key's revision
// when the key is still this instance's at this epoch, the lease lost (why) when it is somebody else's or
// gone, and an error when the key cannot be read — a renewal not made, which loses nothing yet.
func (l *Lease) renewMoved(ctx context.Context, epoch uint64, value []byte, revision uint64,
refused error) (next uint64, lost error, err error) {
entry, err := l.kv.Get(ctx, Key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return 0, fmt.Errorf("the renewal at revision %d was refused and the key is gone, free for another "+
"controller to take: %w", revision, refused), nil
}
if err != nil {
return 0, nil, fmt.Errorf("the renewal at revision %d was refused (%v) and the key could not be read: %w",
revision, refused, err)
}
var h Holder
mine := json.Unmarshal(entry.Value(), &h) == nil && h.Instance == l.o.Holder.Instance &&
(h.Epoch == epoch || h.Epoch == 0 && entry.Revision() == epoch)
if !mine {
return 0, fmt.Errorf("the renewal at revision %d was refused: another holder wrote the key (%s, epoch %d, "+
"revision %d): %w", revision, h.Instance, h.Epoch, entry.Revision(), refused), nil
}
next, err = l.update(ctx, Key, value, entry.Revision())
return next, nil, err
}
// delayed is a renewal that could not be made: said at the first of a run, told to Delayed, and the lease
// kept — or lost, when its key's age has passed meanwhile.
func (l *Lease) delayed(why error) error {
l.mu.Lock()
now := l.o.Now()
if !now.Before(l.keptUntil) {
l.mu.Unlock()
return l.lose(l.overdue(why))
}
l.failing++
if l.failing == 1 {
l.failingSince = now
}
first, epoch, renewed, validUntil, keptUntil := l.failing == 1, l.epoch, l.renewed, l.validUntil, l.keptUntil
l.mu.Unlock()
if first {
l.o.Say("the controller lease (epoch %d) could not be renewed: %v — it is kept: no new act starts after "+
"%s and it is lost at %s unless a renewal is made first", epoch, why,
validUntil.UTC().Format(time.RFC3339), keptUntil.UTC().Format(time.RFC3339))
}
if l.o.Delayed != nil {
l.o.Delayed(why, renewed, keptUntil)
}
return fmt.Errorf("%w: %w", ErrDelayed, why)
}
// overdue is why a lease whose key's age passed unrenewed is lost, with the last renewal's failure.
func (l *Lease) overdue(last error) error {
l.mu.Lock()
renewed, failing := l.renewed, l.failing
l.mu.Unlock()
why := fmt.Errorf("no renewal was made within the key's age of %s (the last at %s, %d failed since)",
l.ttl, renewed.UTC().Format(time.RFC3339), failing)
if last != nil {
why = fmt.Errorf("%w: %w", why, last)
}
return why
}
// isWrongLast is the bus refusing a compare-and-set because the key moved.
func isWrongLast(err error) bool {
var apiErr *jetstream.APIError
-212
View File
@@ -4,9 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -22,9 +20,6 @@ import (
// refused is a loss at once; and a bucket raised again from nothing never issues an epoch twice.
//
// go test ./internal/lease/ (each test on a bus of its own: internal/testbus)
//
// Each test runs beside the others (novox/hq issue 717): they share nothing but the clock, and wait on it
// most of their time, so one after another they took half a minute of a check.
// The bounds in these tests are the design's divided by five, so the cases run in seconds.
const (
@@ -98,7 +93,6 @@ func takeWithin(t *testing.T, l *Lease) <-chan uint64 {
}
func TestTheSecondControllerWaitsAndTakesAHigherEpochOnHandover(t *testing.T) {
t.Parallel()
_, bucket := aBucket(t)
a, b := aController(t, bucket, "a", nil), aController(t, bucket, "b", nil)
@@ -152,7 +146,6 @@ func TestTheSecondControllerWaitsAndTakesAHigherEpochOnHandover(t *testing.T) {
}
func TestAHolderThatStopsRenewingStopsActingBeforeItIsTakenOver(t *testing.T) {
t.Parallel()
_, bucket := aBucket(t)
a, b := aController(t, bucket, "a", nil), aController(t, bucket, "b", nil)
epochA, err := a.Take(t.Context())
@@ -198,7 +191,6 @@ func TestAHolderThatStopsRenewingStopsActingBeforeItIsTakenOver(t *testing.T) {
}
func TestARenewalRefusedIsALossAtOnce(t *testing.T) {
t.Parallel()
js, bucket := aBucket(t)
a := aController(t, bucket, "a", nil)
if _, err := a.Take(t.Context()); err != nil {
@@ -232,7 +224,6 @@ func TestARenewalRefusedIsALossAtOnce(t *testing.T) {
}
func TestKeepingRenewsAcrossManyAges(t *testing.T) {
t.Parallel()
_, bucket := aBucket(t)
a := aController(t, bucket, "a", nil)
epoch, err := a.Take(t.Context())
@@ -256,7 +247,6 @@ func TestKeepingRenewsAcrossManyAges(t *testing.T) {
}
func TestAnEpochIsNeverIssuedTwiceWhenTheBucketStartsOver(t *testing.T) {
t.Parallel()
_, bucket := aBucket(t)
// The mesh has issued epoch 500 before: its store says so. The bucket is new — a bus whose data
// was replaced — and its revisions start at one.
@@ -283,7 +273,6 @@ func TestAnEpochIsNeverIssuedTwiceWhenTheBucketStartsOver(t *testing.T) {
}
func TestALeaseBucketWithoutAnAgeIsRefused(t *testing.T) {
t.Parallel()
url := testbus.URL(t)
conn, err := nats.Connect(url)
if err != nil {
@@ -300,204 +289,3 @@ func TestALeaseBucketWithoutAnAgeIsRefused(t *testing.T) {
t.Fatal("a lease over keys that never expire was opened: a controller that died holding it would hold it for ever")
}
}
// A bus that stalls (novox/hq issue 477): a renewal it does not answer in time is not a lost lease. The
// holder keeps the lease until the key's age passes with no renewal made, starts no new act once half of
// it has, acts again on the next renewal made, and loses it at once only to another holder.
// stallable puts l's renewals on a bus that stalls while the answer is true, as one whose JetStream API
// does: each renewal waits out its bound and fails with the deadline. landed is a stall that swallows
// only the answer: the write is made. Called before l is kept.
func stallable(l *Lease, landed bool) *atomic.Bool {
real := l.update
stalled := new(atomic.Bool)
l.update = func(ctx context.Context, key string, value []byte, last uint64) (uint64, error) {
if !stalled.Load() {
return real(ctx, key, value, last)
}
if landed {
_, _ = real(ctx, key, value, last)
}
<-ctx.Done()
return 0, ctx.Err()
}
return stalled
}
// keeping takes the lease for l and keeps it until the test ends, answering the epoch.
func keeping(t *testing.T, l *Lease) uint64 {
t.Helper()
epoch, err := l.Take(t.Context())
if err != nil {
t.Fatal(err)
}
go l.Keep(t.Context())
return epoch
}
// delays is told of every renewal that failed without losing l's lease.
func delays(l *Lease) <-chan error {
told := make(chan error, 64)
l.o.Delayed = func(why error, _, _ time.Time) {
select {
case told <- why:
default:
}
}
return told
}
func TestAFailedRenewalKeepsTheLeaseUntilItsTTL(t *testing.T) {
t.Parallel()
_, bucket := aBucket(t)
a := aController(t, bucket, "a", nil)
told := delays(a)
stalled := stallable(a, false)
epoch := keeping(t, a)
stalled.Store(true)
select {
case why := <-told:
if !errors.Is(why, context.DeadlineExceeded) {
t.Fatalf("a renewal the bus did not answer was told as %v", why)
}
case <-time.After(3 * testRenew):
t.Fatal("a renewal the bus did not answer was told to nobody")
}
renewed := a.Renewed()
const slack = 150 * time.Millisecond
// Within the key's age the lease is kept, though no renewal is made: no other controller could take it.
for time.Now().Before(renewed.Add(testTTL - slack)) {
select {
case <-a.Lost():
t.Fatalf("a lost the lease %s after its last renewal, within the key's age of %s: %v",
time.Since(renewed), testTTL, a.LostWhy())
default:
}
since := time.Since(renewed)
e, err := a.Epoch()
switch {
case since < testTTL/2-slack && (err != nil || e != epoch):
t.Fatalf("a starts no act %s after its last renewal, within half the key's age: %d, %v", since, e, err)
case since > testTTL/2+slack && !errors.Is(err, ErrNotHeld):
t.Fatalf("a still starts acts %s after its last renewal, past half the key's age: %d, %v", since, e, err)
}
time.Sleep(20 * time.Millisecond)
}
// Past it, the lease is lost: the key may be somebody else's.
select {
case <-a.Lost():
case <-time.After(2 * slack):
t.Fatalf("a still holds the lease %s after its last renewal, past the key's age of %s",
time.Since(renewed), testTTL)
}
if why := a.LostWhy(); why == nil || !strings.Contains(why.Error(), "no renewal was made within the key's age") {
t.Fatalf("a lost the lease for %v", why)
}
if _, err := a.Epoch(); !errors.Is(err, ErrNotHeld) {
t.Fatalf("a acts after losing the lease: %v", err)
}
}
func TestARenewalMadeAfterAStallOpensTheGateAgain(t *testing.T) {
t.Parallel()
_, bucket := aBucket(t)
a := aController(t, bucket, "a", nil)
bus := stallable(a, false)
epoch := keeping(t, a)
bus.Store(true)
stalled := time.Now()
for a.Held() {
if time.Since(stalled) > testTTL {
t.Fatal("a still starts acts a whole key's age into a stall")
}
time.Sleep(20 * time.Millisecond)
}
bus.Store(false)
for !a.Held() {
select {
case <-a.Lost():
t.Fatalf("a lost the lease to a stall shorter than the key's age: %v", a.LostWhy())
default:
}
if time.Since(stalled) > 2*testTTL {
t.Fatal("a never acted again after the bus answered")
}
time.Sleep(20 * time.Millisecond)
}
if e, err := a.Epoch(); err != nil || e != epoch {
t.Fatalf("a acts again as %d, %v; it held epoch %d", e, err, epoch)
}
// And keeps it, renewing, across more than one age of the key.
time.Sleep(testTTL + testRenew)
if !a.Held() {
t.Fatalf("a stopped acting after the stall ended: %v", a.LostWhy())
}
}
func TestARenewalWrittenThoughItsAnswerWasLostIsStillTheHolders(t *testing.T) {
t.Parallel()
_, bucket := aBucket(t)
a := aController(t, bucket, "a", nil)
bus := stallable(a, true)
epoch := keeping(t, a)
// The bus writes each renewal and its answer never arrives: the key moves past the revision a
// knows, and is still a's.
bus.Store(true)
stalled := time.Now()
for a.Held() {
if time.Since(stalled) > testTTL {
t.Fatal("a still starts acts a whole key's age into a stall")
}
time.Sleep(20 * time.Millisecond)
}
bus.Store(false)
deadline := time.Now().Add(2 * testRenew)
for !a.Held() {
select {
case <-a.Lost():
t.Fatalf("a lost the lease to its own renewal: %v", a.LostWhy())
default:
}
if time.Now().After(deadline) {
t.Fatal("a never renewed over the renewal the stall wrote")
}
time.Sleep(20 * time.Millisecond)
}
h, found, err := a.Current(t.Context())
if err != nil || !found || h.Instance != "a" || h.Epoch != epoch {
t.Fatalf("the key says %+v (%v, %v), want a at epoch %d", h, found, err, epoch)
}
}
func TestAnotherHolderSeenAfterAStallIsALossAtOnce(t *testing.T) {
t.Parallel()
js, bucket := aBucket(t)
a := aController(t, bucket, "a", nil)
bus := stallable(a, false)
keeping(t, a)
bus.Store(true)
time.Sleep(testRenew + testRenew/2)
// While a's renewals go unanswered, somebody else writes the key.
kv, err := js.KeyValue(t.Context(), bucket)
if err != nil {
t.Fatal(err)
}
if _, err := kv.Put(t.Context(), Key, []byte(`{"instance":"b","epoch":1}`)); err != nil {
t.Fatal(err)
}
bus.Store(false)
renewed := a.Renewed()
select {
case <-a.Lost():
if lostAt := time.Since(renewed); lostAt >= testTTL {
t.Fatalf("a was told it lost the lease only %s after its last renewal: another holder is a loss at once", lostAt)
}
case <-time.After(testTTL):
t.Fatal("a was not told another controller holds the lease")
}
if why := a.LostWhy(); why == nil || !strings.Contains(why.Error(), "another holder") {
t.Fatalf("a lost the lease for %v", why)
}
}
+2 -50
View File
@@ -114,23 +114,11 @@ type ReuseAsk struct {
// CheckedBuild is one build a check's verdict names: the pull request it checked, its ask, and the build.
type CheckedBuild struct {
Pull PullRef `json:"pull"`
Check string `json:"check"`
// Head is the pull request's head the check judged, as its verdict says it (twelve characters): a build is
// taken or kept only by a check of the same head (novox/hq issue 703).
Head string `json:"head,omitempty"`
Pull PullRef `json:"pull"`
Check string `json:"check"`
Built CheckBuilt `json:"built"`
}
// SameHead says two spellings of a commit name the same one: both said, one the other's beginning, at least
// twelve characters of it (a verdict says twelve).
func SameHead(a, b string) bool {
if len(a) > len(b) {
a, b = b, a
}
return len(a) >= 12 && strings.HasPrefix(b, a)
}
// PullRef names a pull request.
type PullRef struct {
Owner string `json:"owner"`
@@ -167,12 +155,6 @@ type CheckRequest struct {
// the controller, judged by itself; JudgeValidator for a change to the node-engine, judged by the
// running controller built with the change's validator in place of the one it vendors.
Judge string `json:"judge,omitempty"`
// Release is the release branch the pull request merges into (novox/hq ADR 0358): its base, when that is not its
// repository's trunk and mesh-controller has a branch of that name. The gate is then judged by the controller
// built from mesh-controller's release branch — a change to the controller still by itself, a change to the
// node-engine with its own validator — and each repository beside it is read at its branch of that name where it
// has one (CheckedOut.Else otherwise). Empty for every other base, checked as before it.
Release string `json:"release,omitempty"`
// Group names a delivery group whose members' heads are composed together with this one (novox/hq ADR
// 0239), and Members are those other heads, each cloned beside it and laid over the mesh in turn. The
// repository's own check is not run for a group: each member's pull request runs its own.
@@ -182,16 +164,6 @@ type CheckRequest struct {
// each, from the merge it judged, into the scratch namespace once it has judged and not failed, for the walk to
// take. A build seat from before it builds nothing, and the walk builds as before.
Builds []CheckBuild `json:"builds,omitempty"`
// Running is, keyed "<module>/<artifact>", each artifact of the build the mesh has registered for its module — the
// build its machines are sent (novox/hq issue 721): what a build for the merge is compared with, never the newest
// build, which may be one its gate failed and the mesh put back. A module absent is compared with nothing, and
// moves. A controller from before it sends none, and nothing is said unchanged.
Running map[string]string `json:"running,omitempty"`
// Named are the builds the seat's verdicts of earlier checks of this pull request at this head name, within the
// week check builds are kept (novox/hq issue 703): asked again because only the trunk moved, the check keeps a
// build kept under a module's unchanged key, instead of building it again, only when one of these names its key
// and every digest. Never another pull request's, nor an earlier head's. A build seat from before it builds.
Named []CheckedBuild `json:"named,omitempty"`
// Try marks a branch tried before its pull request (novox/hq issue 686): the repository's own check for a
// pushed branch or commit, asked by a builder through the controller's `try` verb. Its outcome is read from
// the build's log (StepTried) and said to nobody: no status on the forge, nothing recorded or registered,
@@ -240,14 +212,6 @@ type CheckBuilt struct {
Made []MadeArtifact `json:"made,omitempty"`
NotBuilt string `json:"not-built,omitempty"`
Took string `json:"took,omitempty"`
// Kept names the check whose build this check kept instead of building, its key unchanged (novox/hq issue 703):
// a walk takes it as that check's build. Empty when this check built it.
Kept string `json:"kept,omitempty"`
// Same says every artifact came out the same as the build the mesh holds of the module, and SameAs the held
// reference each was compared with (novox/hq issue 721): a walk cut while the mesh still holds those builds leaves
// the module out. Empty when it differs, or could not be compared.
Same string `json:"same,omitempty"`
SameAs map[string]string `json:"same-as,omitempty"`
}
// GroupMember is one other head of a delivery group's composed check.
@@ -273,10 +237,6 @@ const (
type CheckedOut struct {
Repository string `json:"repository"`
Ref string `json:"ref,omitempty"`
// Else is the ref read when the repository has no branch Ref (novox/hq ADR 0358 §3): beside a check of a pull
// request into a release branch, Ref is that branch and Else the repository's trunk. Empty: Ref is read, or the
// check cannot run.
Else string `json:"else,omitempty"`
}
// CheckOutcome is a merge check's verdict.
@@ -301,14 +261,6 @@ type CheckOutcome struct {
Members []CheckedMember `json:"members,omitempty"`
// Built are the builds it made for its merge (novox/hq ADR 0350 §1).
Built []CheckBuilt `json:"built,omitempty"`
// Release is the release branch the check was judged for (novox/hq ADR 0358 §4), JudgedBy the commit of the
// controller that judged its gate, as twelve characters, and BesideAt the branch each repository beside it was
// read at, by directory. Empty from a check of any other base, and from a build seat from before it.
Release string `json:"release,omitempty"`
JudgedBy string `json:"judged-by,omitempty"`
BesideAt map[string]string `json:"beside-at,omitempty"`
// Validator says which node-engine validator the judge of a release-branch check was built with (ADR 0358 §2).
Validator string `json:"validator,omitempty"`
}
// SourceOnSeat names a repository by the seat whose holder serves it and its path there.
+1 -1
View File
@@ -332,7 +332,7 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
// as the controller says it for its own long handlers, so the server neither hands
// the ask to a second machine nor counts the wait against its deliveries.
working := make(chan struct{})
go stillWorking(msg, working, heartbeatWhileWorking)
go stillWorking(msg, working)
do(ctx, build)
close(working)
}
+33
View File
@@ -32,6 +32,9 @@ const BusModule = "nats"
const (
BusSlowConsumerEvent = "bus.slow-consumer"
BusConnectionClosedEvent = "bus.connection-closed"
// BusWatchEvent is the bus module's watch saying it is there after every look, and whether it is healthy
// (novox/hq ADR 0357, review 274): a watch that stops saying it is a watch nobody would otherwise hear.
BusWatchEvent = "bus.watch"
)
// AdvisoryConnectionClosed is a connection the bus closed for breaking its rules, as conditions name it.
@@ -233,3 +236,33 @@ func MergeAdvisories(a, b []Advisory) []Advisory {
}
return out
}
// BusWatchBeat is the bus module's watch's newest heartbeat.
type BusWatchBeat struct {
At time.Time `json:"at"`
Healthy bool `json:"healthy"`
Why string `json:"why,omitempty"`
// Machine is the machine the runtime stamped on it (`x-node`).
Machine string `json:"-"`
}
// BusWatchLast is the newest heartbeat of the bus module's watch, read directly from the events stream; false
// when the stream holds none — a bus module older than its watch, which says nothing either way.
func (s *Server) BusWatchLast(ctx context.Context) (BusWatchBeat, bool, error) {
if s.js == nil {
return BusWatchBeat{}, false, errors.New("this control plane is not on the bus, so it cannot read the bus module's watch")
}
msg, err := s.js.Context().GetLastMsg(broker.EventsStream, EventSubject(BusModule, BusWatchEvent), nats.Context(ctx))
if errors.Is(err, nats.ErrMsgNotFound) {
return BusWatchBeat{}, false, nil
}
if err != nil {
return BusWatchBeat{}, false, fmt.Errorf("reading the bus module's watch's heartbeat: %w", err)
}
var b BusWatchBeat
if err := json.Unmarshal(msg.Data, &b); err != nil || b.At.IsZero() {
b = BusWatchBeat{At: msg.Time, Why: "its heartbeat could not be read"}
}
b.Machine = msg.Header.Get("x-node")
return b, true, nil
}
+26
View File
@@ -6,6 +6,8 @@ import (
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/testbus"
)
@@ -119,3 +121,27 @@ func TestNatsBusFaultsReadAtMostTheBoundAndSaySo(t *testing.T) {
t.Fatalf("a read past its bound read as %+v", got)
}
}
// **The bus module's watch is heard by its heartbeat** (review 274): the newest is read directly, with the
// machine the runtime stamped on it; none at all is a bus module older than its watch.
func TestNatsTheBusWatchsNewestHeartbeatIsRead(t *testing.T) {
s := aBusWithEvents(t)
if _, heard, err := s.BusWatchLast(t.Context()); err != nil || heard {
t.Fatalf("a stream with no heartbeat read as heard %v, %v", heard, err)
}
at := time.Now().UTC().Truncate(time.Second)
for i, healthy := range []bool{true, false} {
msg := nats.NewMsg(EventSubject(BusModule, BusWatchEvent))
msg.Header.Set("x-node", "anchor")
msg.Data, _ = json.Marshal(map[string]any{"at": at.Add(time.Duration(i) * time.Second), "healthy": healthy,
"why": "the watch of slow consumers last read the bus's closed connections 95s ago"})
if _, err := s.js.Context().PublishMsg(msg); err != nil {
t.Fatal(err)
}
}
b, heard, err := s.BusWatchLast(t.Context())
if err != nil || !heard || b.Healthy || b.Machine != "anchor" || !b.At.Equal(at.Add(time.Second)) ||
!strings.Contains(b.Why, "95s ago") {
t.Fatalf("read %+v, %v, %v", b, heard, err)
}
}
-48
View File
@@ -37,51 +37,3 @@ func TestNatsTheBuildsAWalkMayTakeAreTheChecksOutcomesOfItsPullRequests(t *testi
t.Fatalf("read %+v", got)
}
}
// A build is read with the head its check judged, and a kept build is named as the check's that built it (novox/hq
// issue 703).
func TestNatsAKeptBuildIsTheBuildersCheckAndEveryBuildSaysItsHead(t *testing.T) {
js := aBus(t)
since := time.Now().Add(-time.Minute)
say := func(r BuildResult) {
body, _ := json.Marshal(r)
if _, err := js.Context().Publish(BuildOutcomeOf(TheBuildMachine), body); err != nil {
t.Fatal(err)
}
}
mine := &CheckRequest{Owner: "novox", Repo: "shop", Number: 7}
app := CheckBuilt{Module: "app", Key: "r1:1", Made: []MadeArtifact{{Name: "server", Reference: "s/app/server@sha256:a"}}}
say(BuildResult{ID: "build-1", Checked: mine, Check: &CheckOutcome{Verdict: "pass", Head: "aaaaaaaaaaaa",
Built: []CheckBuilt{app}}})
kept := app
kept.Kept = "build-1"
say(BuildResult{ID: "build-2", Checked: mine, Check: &CheckOutcome{Verdict: "pass", Head: "aaaaaaaaaaaa",
Built: []CheckBuilt{kept}}})
got, err := ReadCheckBuilds(t.Context(), js, TheBuildMachine, []PullRef{{Owner: "novox", Repo: "shop", Number: 7}}, since)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Check != "build-1" || got[0].Built.Kept != "build-1" || got[0].Head != "aaaaaaaaaaaa" ||
got[1].Check != "build-1" || got[1].Head != "aaaaaaaaaaaa" {
t.Fatalf("read %+v", got)
}
}
// Two spellings of a head are the same head only when both say at least twelve characters of it.
func TestSameHeadNeedsTwelveCharactersOfBoth(t *testing.T) {
for _, c := range []struct {
a, b string
same bool
}{
{"aaaaaaaaaaaa", "aaaaaaaaaaaabbbb", true},
{"aaaaaaaaaaaabbbb", "aaaaaaaaaaaa", true},
{"aaaaaaaaaaaa", "aaaaaaaaaaab", false},
{"", "", false},
{"aaaa", "aaaaaaaaaaaa", false},
} {
if SameHead(c.a, c.b) != c.same {
t.Errorf("SameHead(%q, %q) is %v", c.a, c.b, !c.same)
}
}
}
+1 -25
View File
@@ -259,8 +259,7 @@ func DeadLetterNamed(js nats.JetStreamContext, id uint64) (DeadLetter, error) {
// work queue has one worker per subject, so only the worker that gave it up takes it, and DeliverAgain
// removes the original from the queue first, so there are never two (novox/hq issue 334); only while the
// worker that gave it up is on the bus. Any other stream's
// message is refused, with why — but a module's own stream's, which goes back as an event while its consumer is
// still granted it (novox/hq ADR 0363 §7): an ask to a seat the controller does not ask would need a publish it is
// message is refused, with why: an ask to a seat the controller does not ask would need a publish it is
// not granted, in its asker's name (ADR 0264's consequences, ADR 0259 §3), and a stream that is neither
// would reach every consumer of its subject.
func AgainTo(js nats.JetStreamContext, d DeadLetter) (string, error) {
@@ -281,29 +280,6 @@ func AgainTo(js nats.JetStreamContext, d DeadLetter) (string, error) {
return "", fmt.Errorf("whether %s can receive dead letter %d cannot be read: %w", d.Who, d.ID, err)
}
return d.Subject, nil
case d.Stream != broker.EventsStream && broker.OnAModuleStream(d.Subject):
// **A module stream's event goes back to its consumer only while it is still granted** (novox/hq ADR
// 0363 §7): its consumer on that stream must still be there and still filter the subject, or delivering
// it again would hand a module what its grant no longer names. Then it is delivered as an event is, on
// EVENTS under the consumer's own again-subject, which the module's consumer there filters.
info, err := js.ConsumerInfo(d.Stream, d.Consumer)
if errors.Is(err, nats.ErrConsumerNotFound) {
return "", fmt.Errorf("dead letter %d was given up by %s, which no longer hears %s, so it would reach a "+
"module no longer granted it. Drop it", d.ID, d.Who, d.Stream)
}
if err != nil {
return "", fmt.Errorf("whether %s still hears %s cannot be read: %w", d.Who, d.Stream, err)
}
granted := false
for _, f := range append([]string{info.Config.FilterSubject}, info.Config.FilterSubjects...) {
if f != "" && broker.SubjectsOverlap(f, d.Subject) {
granted = true
}
}
if !granted {
return "", fmt.Errorf("dead letter %d is on %s, which %s is no longer granted. Drop it", d.ID, d.Subject, d.Who)
}
d.Stream = broker.EventsStream
case d.Stream != broker.EventsStream:
return "", fmt.Errorf("dead letter %d is from %s, and only an event or an ask the controller made is "+
"delivered again: publishing it again would reach every consumer of its subject, or need a grant the "+
@@ -1,48 +0,0 @@
package link
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/broker"
)
// A message given up on a module's own stream is delivered again on EVENTS to the consumer that gave it up,
// and only while that consumer is still granted it (novox/hq ADR 0363 §7).
func TestAModuleStreamsDeadLetterGoesBackOnlyWhileGranted(t *testing.T) {
js := aBus(t)
mail := broker.ModuleStream{Module: "mesh-mail", Name: "MAIL", Events: []string{"mail.*.message.added"},
MaxAgeDays: 7, MaxMiB: 16, Restricted: true}
if err := js.EnsureStream(mail.Stream()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = js.Context().DeleteStream("MAIL") })
subject := broker.ModuleStreamSubject("mesh-mail", "mail.mesh.message.added")
heard := broker.Heard{Stream: "MAIL", Emitter: "mesh-mail", Filters: []string{subject}}
reader := broker.Principal{Kind: broker.KindModule, Node: "ace", Module: "reader", Hears: []broker.Heard{heard},
PasswordHash: "x"}
users := broker.ConsumersOf([]broker.Principal{reader})
if len(users) != 2 {
t.Fatalf("the reader's consumers: %+v", users)
}
for _, c := range users {
if err := js.EnsureConsumer(c.Consumer); err != nil {
t.Fatal(err)
}
}
d := DeadLetter{ID: 9, Stream: "MAIL", Consumer: "ace_reader", Subject: subject}
got, err := AgainTo(js.Context(), d)
if err != nil {
t.Fatal(err)
}
if got != broker.AgainSubject("ace_reader", subject) {
t.Fatalf("delivered again on %s", got)
}
if err := js.RemoveConsumer("MAIL", "ace_reader"); err != nil {
t.Fatal(err)
}
if _, err := AgainTo(js.Context(), d); err == nil || !strings.Contains(err.Error(), "no longer hears MAIL") {
t.Fatalf("delivered again to a consumer no longer granted: %v", err)
}
}
+4 -15
View File
@@ -172,13 +172,10 @@ type Announcement struct {
// which branch, producing which commit. The mesh matches it against every module's recorded
// source and builds what moved, bases first.
type SourceMoved struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Base string `json:"base"`
Head string `json:"head"`
// HeadSHA is the head commit the forge merged (novox/hq issue 703): a walk takes only a build a check of that
// head made. Empty from a forge module from before it, and then the walk takes no check's build.
HeadSHA string `json:"head_sha,omitempty"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Base string `json:"base"`
Head string `json:"head"`
Commit string `json:"merge_commit_sha"`
CloneURL string `json:"clone_url"`
HTMLURL string `json:"html_url"`
@@ -293,14 +290,6 @@ type Checked struct {
Trunk string `json:"trunk,omitempty"`
Beside map[string]string `json:"beside,omitempty"`
Took string `json:"took,omitempty"`
// Release is the release branch the check judged for (novox/hq ADR 0358 §4), JudgedBy the commit of the
// controller that judged its gate (twelve characters), and BesideAt the branch each repository beside it was
// read at, by directory: so a reader sees a release-branch judgement apart from a trunk one. Empty otherwise.
Release string `json:"release,omitempty"`
JudgedBy string `json:"judged-by,omitempty"`
BesideAt map[string]string `json:"beside-at,omitempty"`
// Validator says which node-engine validator the judge of a release-branch check was built with (ADR 0358 §2).
Validator string `json:"validator,omitempty"`
// Built are the builds the check made for its merge, each with its reuse key and digests, or why not
// (novox/hq ADR 0350 §1): what the walk of the merge takes instead of building.
Built []CheckBuilt `json:"built,omitempty"`
+5 -13
View File
@@ -375,13 +375,6 @@ func ReadCheckBuilds(ctx context.Context, js *broker.JetStream, seat string, pul
for _, p := range pulls {
wanted[p] = true
}
return readCheckBuilds(ctx, js, seat, since, func(p PullRef, _ CheckBuilt) bool { return wanted[p] })
}
// readCheckBuilds is every build the seat's checks said they made, or kept, that wanted takes. A kept build is named
// as the check's that built it (novox/hq issue 703): the record under its key is that check's.
func readCheckBuilds(ctx context.Context, js *broker.JetStream, seat string, since time.Time,
wanted func(PullRef, CheckBuilt) bool) ([]CheckedBuild, error) {
subject := BuildOutcomeOf(seat)
sub, err := js.Context().PullSubscribe(subject, "",
nats.BindStream(broker.EventsStream), nats.StartTime(since), nats.AckNone())
@@ -409,13 +402,12 @@ func readCheckBuilds(ctx context.Context, js *broker.JetStream, seat string, sin
continue
}
pull := PullRef{Owner: r.Checked.Owner, Repo: r.Checked.Repo, Number: r.Checked.Number}
if !wanted[pull] {
continue
}
for _, b := range r.Check.Built {
if b.NotBuilt == "" && b.Key != "" && wanted(pull, b) {
check := r.ID
if b.Kept != "" {
check = b.Kept
}
out = append(out, CheckedBuild{Pull: pull, Check: check, Head: r.Check.Head, Built: b})
if b.NotBuilt == "" && b.Key != "" {
out = append(out, CheckedBuild{Pull: pull, Check: r.ID, Built: b})
}
}
}
+5 -6
View File
@@ -220,22 +220,21 @@ func (n *natsInbound) deliver(ctx context.Context, act func(context.Context, Con
// stops saying so, and the bus redelivers exactly as it should.
working := make(chan struct{})
defer close(working)
go stillWorking(msg, working, heartbeatWhileWorking)
go stillWorking(msg, working)
}
act(ctx, m)
}
// heartbeatWhileWorking is how often a handler still running tells the bus so — comfortably inside
// the shortest acknowledgement window the mesh gives any of its consumers. A variable only so a test can
// outlive a window of a second in a few (novox/hq issue 717); nothing else sets it.
var heartbeatWhileWorking = 10 * time.Second
// the shortest acknowledgement window the mesh gives any of its consumers.
const heartbeatWhileWorking = 10 * time.Second
// stillWorking keeps one message alive until the work on it returns.
//
// An error is not worth reporting: what the bus does when it is not told is redeliver, which is
// exactly what happens if this fails, and the handler's own outcome is the thing worth logging.
func stillWorking(msg *nats.Msg, done <-chan struct{}, every time.Duration) {
tick := time.NewTicker(every)
func stillWorking(msg *nats.Msg, done <-chan struct{}) {
tick := time.NewTicker(heartbeatWhileWorking)
defer tick.Stop()
for {
select {

Some files were not shown because too many files have changed in this diff Show More