diff --git a/.gitignore b/.gitignore index ff196481..3f98af11 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ # A built binary. The lab writes one here when pointed at the repository root by mistake; # built artifacts belong in build/, which is already ignored. -/mesh-builder /mesh-controller /postgres-provisioner /redis-provisioner diff --git a/Makefile b/Makefile index 8885d70c..9636d6e0 100644 --- a/Makefile +++ b/Makefile @@ -46,17 +46,6 @@ image: @echo @docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes' -# The builder ships as an image too, because it is a module the mesh assigns rather than a program -# somebody starts on a machine by hand. -BUILDER_IMAGE ?= mesh-builder:$(VERSION) -BUILDER_DEV_TAG ?= mesh-builder:development - -builder-image: - @test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE="; exit 1; } - docker build --build-arg GO_BASE=$(GO_BASE) -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) . - @echo - @docker image inspect $(BUILDER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes' - # The provisioner ships as an image too, because it is the thing that makes a sealed credential # true on a machine -- and the mesh cannot, having discarded the plaintext. PROVISIONER_IMAGE ?= mesh-provision-postgres:$(VERSION) @@ -90,26 +79,6 @@ redis-provisioner-image: @echo @docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes' -# The whole check. Raises a database, runs the repository's own check against it -- merge-check.sh, -# the very script `mesh/repo-check` runs -- and takes the database down again, including when the -# check fails, which is why the teardown is not conditional and the script's exit status is the -# target's. -# -# **It calls the script rather than restating it** (novox/hq issue 431): this target used to list its -# own steps, its formatting step walked vendor/ where the script's does not, and it failed on -# third-party code no change could fix -- so a developer's check and the gate disagreed, and the steps -# after formatting never ran through it. The script is the one list of steps; this target only gives it -# a database (MESH_TEST_POSTGRES, exported above). -# -# **Two levels** (issue 895): the unit level, `go test ./...` untagged, timed and held to a bound; and the integration -# level, `-tags integration`, the tests that start a bus, a store or another process. Each is said with its time. -# -# **Packages in parallel, under the race detector, each test on a bus of its own** (internal/testbus), -# as the script runs them: a bus per test, of the release the mesh runs, makes the suite the same in any -# order, and the timeout bounds a hang to a failure with a stack, never a stalled gate. -check: postgres - @sh merge-check.sh ; status=$$? ; docker rm -f -v $(PG_CONTAINER) >/dev/null 2>&1 || true ; exit $$status - # The unit level alone (untagged: no bus, no store, no other process), which a developer runs continuously. The tests # that need a bus or the store carry `//go:build integration` and run in `check`, not here; without a database the # store's tests would skip rather than fail anyway. @@ -119,8 +88,7 @@ test: vet: go vet ./... -# Quick steps for a developer, not part of `check`. The directories gofmt reads must be the ones -# merge-check.sh lists, never `.`, which walks vendor/ (novox/hq issue 431). +# A quick step for a developer: the checker runs the build module's own formatting and vet (ADR 0501 §5, D7). fmt: @unformatted=$$(gofmt -l cmd internal examples) ; \ if [ -n "$$unformatted" ] ; then echo "not gofmt'd:" ; echo "$$unformatted" ; exit 1 ; fi diff --git a/RELEASE-57.md b/RELEASE-57.md deleted file mode 100644 index 140ccff6..00000000 --- a/RELEASE-57.md +++ /dev/null @@ -1,6 +0,0 @@ -# Release 57: config and provisioning - -This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package -merges here through its own reviewed pull request; the branch reaches main as one pull request, in one -delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog -and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request. diff --git a/check-nested-modules.sh b/check-nested-modules.sh deleted file mode 100644 index ee4b8905..00000000 --- a/check-nested-modules.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh -# The unit tests of every Go module of its own below this repository's root, run from the root, for merge-check.sh -# and for the test that tries it on a tree (merge_check_nested_test.go). Its own file so that the test needs no -# particular merge-check.sh: the check that judges a change to merge-check.sh is main's. -set -eu -cgo="${cgo:-0}" -race="${race:-}" - -# nested_modules vets and tests every Go module of its own below the root, `modules//go.mod` (novox/hq ADR 0420 -# §5, issue 1025): `./...` stops at a nested go.mod, so a module that lives whole in this repository would have its -# unit tests run by nobody. None is a pass. Each runs in its own directory, outside the vendor directory this -# repository's own packages are built from; a nested module vendors what the forge holds (its own `vendor/`, so the -# check runs offline), as this repository's root module does (issue 1019). A failing module fails the check; the others are still run. -nested_modules() { - nested=0 - for gomod in modules/*/go.mod; do - [ -e "$gomod" ] || continue - dir=$(dirname "$gomod") - echo "nested module $dir:" - if ! (cd "$dir" && GOFLAGS= CGO_ENABLED=0 go vet ./... && - GOFLAGS= CGO_ENABLED="${cgo:-0}" go test ${race:-} -count=1 -timeout 30m ./...); then - echo "nested module $dir FAILED" - nested=1 - fi - done - return $nested -} - -nested_modules diff --git a/cmd/mesh-builder/Dockerfile b/cmd/mesh-builder/Dockerfile deleted file mode 100644 index b639b869..00000000 --- a/cmd/mesh-builder/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -ARG ALPINE_BASE=alpine:3 -ARG GO_BASE=golang:1.25-alpine -# The builder, as a module ships one. -# -# Not FROM scratch, unlike the control plane: this one runs git and a container client, so it -# needs a filesystem with them on it. That is the honest cost of a machine whose job is to build — -# and it is why building is a MODULE on a machine that has a runtime rather than something the -# control plane does (novox/hq ADR 0005). -FROM ${GO_BASE} AS build -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' -o /mesh-builder ./cmd/mesh-builder - -FROM ${ALPINE_BASE} -# git to clone what it is asked to build, and the docker client to build and push it. The daemon -# is the machine's, reached through its socket — a build machine shares the runtime it was given -# rather than running one inside itself. -RUN apk add --no-cache git docker-cli -COPY --from=build /mesh-builder /usr/local/bin/mesh-builder -ENTRYPOINT ["/usr/local/bin/mesh-builder"] diff --git a/cmd/mesh-builder/busy.go b/cmd/mesh-builder/busy.go deleted file mode 100644 index 049e97b2..00000000 --- a/cmd/mesh-builder/busy.go +++ /dev/null @@ -1,235 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/novox/mesh-controller/internal/builder" -) - -// **The build agent says whether it is busy, and being asked drains it** (novox/hq ADR 0335 §1, §5). Its -// container declares `busy` as `mesh-builder busy`: the node-engine runs it inside the container before it -// replaces it. It exits 0 while a build is in hand and 1 when none is; and from the first ask the agent takes -// no new build request, so its answer ends once the build in hand ends — a replaced builder finishes the check -// it runs, instead of the build queue handing it out again from the start (issue 594). -// -// Two marks in the container's own temporary directory, as the ready mark (ready.go): a new container starts -// without them, and a restarted one removes them before it takes work again. -// -// - in hand: written by the agent when it claims a request, before it reads whether it is drained; removed -// when the build ends. -// - drained: written by the ask, before it reads whether a build is in hand. -// -// Each side writes its own mark before it reads the other's, so an ask racing a fetch sees the build in hand, -// or the agent sees the drain and hands the request back; never neither. - -func inHandMark() string { return filepath.Join(os.TempDir(), "mesh-builder.in-hand") } -func drainMark() string { return filepath.Join(os.TempDir(), "mesh-builder.drained") } - -// The drain lapses when no ask has come for drainLapse: the node-engine asks every 20 seconds for as long as it -// really waits, so an agent whose replacement was withdrawn — the declaration put back, the walk stopped — -// takes work again rather than staying drained for ever (review of ADR 0335). And an agent drained for longer -// than the node-engine's bound and the slack says it is not ready: the replacement it was drained for never -// came, which is a fault to see. -const ( - drainLapse = 2 * time.Minute - drainTooLong = 15*time.Minute + 5*time.Minute -) - -// drainedAt is when the drain began and when it was last asked, from its mark; false when there is no mark. -func drainedAt() (began, asked time.Time, ok bool) { - info, err := os.Stat(drainMark()) - if err != nil { - return time.Time{}, time.Time{}, false - } - asked = info.ModTime() - began = asked - if raw, err := os.ReadFile(drainMark()); err == nil { - if t, err := time.Parse(time.RFC3339, strings.TrimSpace(string(raw))); err == nil && !t.After(asked) { - began = t - } - } - return began, asked, true -} - -// drained says the agent was asked whether it is busy within drainLapse: it takes no new build request. -func drained() bool { return drainedSince(time.Now()) } - -func drainedSince(now time.Time) bool { - _, asked, ok := drainedAt() - return ok && now.Sub(asked) < drainLapse -} - -// drainedTooLong is the health check's second half: nil unless the agent has been drained, asked all along, for -// longer than its node-engine's bound and the slack. -func drainedTooLong(now time.Time) error { - began, asked, ok := drainedAt() - if !ok || now.Sub(asked) >= drainLapse || now.Sub(began) <= drainTooLong { - return nil - } - return fmt.Errorf("drained since %s and still asked: the replacement its node-engine waits for has not come "+ - "within %s (novox/hq ADR 0335)", began.UTC().Format(time.RFC3339), drainTooLong) -} - -// inHand is true while this process has claimed a build and not yet released it. The mark in the temporary directory -// is only believed while it is: a mark with no build of this process behind it is stale (a crash, a fault in an -// earlier path) and is removed, because left alone it hands back every ask for ever (issue 1768). -var inHand atomic.Bool - -// markRetry is how long an agent that cannot mark a build in hand takes no work, and markFault when it last failed to. -// Taking nothing is the point: an ask fetched and handed back at once, many times a second, spends all its deliveries. -const markRetry = 30 * time.Second - -var ( - markMu sync.Mutex - markFault time.Time - markSaid string -) - -// markFaulty says the agent could not mark a build in hand and has not yet been able to since: it takes no work. Once -// markRetry has passed it tries the mark itself, with no ask in hand, so that an ask is fetched only when marking works. -func markFaulty(now time.Time) bool { - markMu.Lock() - failed := markFault - markMu.Unlock() - if failed.IsZero() { - return false - } - if now.Sub(failed) < markRetry { - return true - } - f, err := os.OpenFile(inHandMark(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) - if err != nil && errors.Is(err, os.ErrExist) && !inHand.Load() { - _ = os.Remove(inHandMark()) - f, err = os.OpenFile(inHandMark(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) - } - if err != nil { - markFailed(err) - return true - } - _ = f.Close() - _ = os.Remove(inHandMark()) - markMu.Lock() - markFault, markSaid = time.Time{}, "" - markMu.Unlock() - fmt.Fprintf(os.Stderr, "the mark of a build in hand can be made again (%s): taking builds\n", inHandMark()) - return false -} - -// markFailed notes that a build could not be marked in hand, and says it when it is a new reason. -func markFailed(err error) { - markMu.Lock() - defer markMu.Unlock() - markFault = time.Now() - if said := err.Error(); said != markSaid { - markSaid = said - fmt.Fprintf(os.Stderr, "cannot mark a build in hand (%s), so this agent takes no build for %s and tries again: %v\n", - inHandMark(), markRetry, err) - } -} - -// claim marks a request in hand, unless the agent is drained: then nothing is in hand, and the request goes -// back to the queue. -func claim() bool { - if !inHand.Load() { - // Nothing of this process is in hand, so a mark already there is stale. - if err := os.Remove(inHandMark()); err == nil { - fmt.Fprintf(os.Stderr, "removed the mark of a build in hand (%s): no build of this agent is in hand\n", inHandMark()) - } - } - // Made, never written over: one build at a time, and a mark already there is a build in hand. - f, err := os.OpenFile(inHandMark(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) - if err != nil { - // Unmarked, an ask would say idle while this build runs: the build is not taken. - markFailed(err) - return false - } - _, err = f.WriteString(time.Now().UTC().Format(time.RFC3339) + "\n") - if closeErr := f.Close(); err == nil { - err = closeErr - } - if err != nil { - _ = os.Remove(inHandMark()) - markFailed(err) - return false - } - if drained() { - _ = os.Remove(inHandMark()) - fmt.Fprintln(os.Stderr, "asked whether busy, so this agent takes no new build: handed back") - return false - } - inHand.Store(true) - return true -} - -// holding says what is in hand, for the ask to print: written once the request is read. -func holding(what string) { - _ = os.WriteFile(inHandMark(), []byte(time.Now().UTC().Format(time.RFC3339)+" "+what+"\n"), 0o644) -} - -// released ends the build in hand. -func released() { - inHand.Store(false) - if err := os.Remove(inHandMark()); err != nil && !errors.Is(err, os.ErrNotExist) { - fmt.Fprintf(os.Stderr, "cannot remove the mark of the build in hand (%s): %v\n", inHandMark(), err) - } -} - -// undrained clears both marks at the start of a holder: a container started again is asked afresh. -func undrained() error { - for _, mark := range []string{drainMark(), inHandMark()} { - if err := os.Remove(mark); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("cannot remove %s: %w", mark, err) - } - } - return nil -} - -// busy is the ask: it drains the agent, then says whether a build is in hand. Nil — exit 0 — while one is. -func busy() (string, error) { - // The mark keeps when the drain began while it is asked within drainLapse, and is written again on every - // ask: its time is the last ask. - began := time.Now().UTC() - if was, _, ok := drainedAt(); ok && drained() { - began = was.UTC() - } - if err := os.WriteFile(drainMark(), []byte(began.Format(time.RFC3339)+"\n"), 0o644); err != nil { - // Not drained, it could take the next build: said, and answered busy only for what is in hand now. - fmt.Fprintf(os.Stderr, "cannot mark this agent drained (%s): %v\n", drainMark(), err) - } - held, err := os.ReadFile(inHandMark()) - if err != nil { - return "", errors.New("idle: no build in hand, and this agent takes no new one") - } - since, what, _ := strings.Cut(strings.TrimSpace(string(held)), " ") - if what == "" { - what = "a build" - } - return fmt.Sprintf("busy: %s, in hand since %s; taking no new build", what, since), nil -} - -var ( - roomMu sync.Mutex - roomSaid bool -) - -// noRoom says this node is under the floor of free space (novox/hq issue 1834): it takes no work, so another build node -// takes the ask instead of this one taking it and ending it in an error. Said once when it starts and again when it ends. -func noRoom(workspace string) bool { - err := builder.RoomToWork("/", workspace) - roomMu.Lock() - defer roomMu.Unlock() - if err != nil && !roomSaid { - fmt.Fprintf(os.Stderr, "taking no builds: %v\n", err) - } else if err == nil && roomSaid { - fmt.Fprintf(os.Stderr, "there is room again: taking builds\n") - } - roomSaid = err != nil - return err != nil -} diff --git a/cmd/mesh-builder/busy_test.go b/cmd/mesh-builder/busy_test.go deleted file mode 100644 index d5f89b7f..00000000 --- a/cmd/mesh-builder/busy_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package main - -import ( - "github.com/novox/mesh-controller/internal/builder" - "os" - "strings" - "sync" - "testing" - "time" -) - -// `mesh-builder busy` answers 0 while a build is in hand and non-zero when idle, and after its first ask the -// agent takes no new build request (novox/hq ADR 0335 §5). -func TestBusyAnswersWhileABuildIsInHandAndDrainsTheAgent(t *testing.T) { - t.Setenv("TMPDIR", t.TempDir()) - if err := undrained(); err != nil { - t.Fatal(err) - } - if drained() { - t.Fatal("an agent never asked is drained") - } - // A build is claimed and runs. - if !claim() { - t.Fatal("an agent never asked refused a build") - } - holding("novox/mesh-controller (check-280)") - said, err := busy() - if err != nil || !strings.Contains(said, "novox/mesh-controller (check-280)") { - t.Fatalf("an agent with a build in hand did not answer busy with what it holds: %q %v", said, err) - } - // From that ask on it takes nothing new: the fetch is not made, and one fetched is handed back. - if !drained() { - t.Fatal("the ask did not drain the agent") - } - if claim() { - t.Fatal("a drained agent took a new build") - } - // The build in hand is still in hand after the refused claim, and busy goes on saying so. - if _, err := busy(); err != nil { - t.Fatalf("a refused claim ended the build in hand: %v", err) - } - // It ends: idle, and still drained. - released() - if _, err := busy(); err == nil { - t.Fatal("an agent with nothing in hand answered busy") - } - if claim() { - t.Fatal("a drained agent took a build after its last one ended") - } - // Started again, it is asked afresh. - if err := undrained(); err != nil { - t.Fatal(err) - } - if drained() || !claim() { - t.Fatal("a holder started again is still drained") - } - released() -} - -// An ask racing a fetch sees the build in hand, or the agent sees the drain and hands the request back: never -// neither, which would replace the container under a build just taken. -func TestAnAskRacingAFetchNeverMissesTheBuild(t *testing.T) { - t.Setenv("TMPDIR", t.TempDir()) - for i := 0; i < 200; i++ { - if err := undrained(); err != nil { - t.Fatal(err) - } - var claimed bool - var askErr error - var wg sync.WaitGroup - wg.Add(2) - go func() { defer wg.Done(); claimed = claim() }() - go func() { defer wg.Done(); _, askErr = busy() }() - wg.Wait() - if claimed && askErr != nil { - t.Fatalf("round %d: the agent took a build and the ask said idle", i) - } - released() - } -} - -// A drain no longer asked lapses: an agent whose replacement was withdrawn after the first ask (a put-back, a -// revert, a stopped walk) takes work again after drainLapse, rather than staying drained with its health green. -func TestADrainNoLongerAskedLapses(t *testing.T) { - t.Setenv("TMPDIR", t.TempDir()) - if err := undrained(); err != nil { - t.Fatal(err) - } - if _, err := busy(); err == nil { - t.Fatal("an idle agent answered busy") - } - if !drained() || claim() { - t.Fatal("an agent just asked takes work") - } - // The last ask was three minutes ago. - old := time.Now().Add(-3 * time.Minute) - if err := os.Chtimes(drainMark(), old, old); err != nil { - t.Fatal(err) - } - if drained() { - t.Fatal("a drain no longer asked for three minutes still holds") - } - if !claim() { - t.Fatal("an agent whose drain lapsed takes no work") - } - released() -} - -// An agent drained, and asked all along, for longer than its node-engine's bound and the slack is not ready: the -// replacement it was drained for never came. One asked again after a lapse starts a new drain. -func TestAnAgentDrainedTooLongIsNotReady(t *testing.T) { - t.Setenv("TMPDIR", t.TempDir()) - if err := undrained(); err != nil { - t.Fatal(err) - } - taking("mesh-build-machine") - if _, err := busy(); err == nil { - t.Fatal("an idle agent answered busy") - } - now := time.Now() - if err := drainedTooLong(now); err != nil { - t.Fatalf("an agent drained a moment ago is not ready: %v", err) - } - // The drain began 25 minutes ago, and the node-engine asked a moment ago. - began := now.Add(-25 * time.Minute).UTC().Format(time.RFC3339) - if err := os.WriteFile(drainMark(), []byte(began+"\n"), 0o644); err != nil { - t.Fatal(err) - } - err := drainedTooLong(time.Now()) - if err == nil || !strings.Contains(err.Error(), "has not come") { - t.Fatalf("an agent drained for 25 minutes reads ready: %v", err) - } - // Each ask keeps when the drain began. - if _, err := busy(); err == nil { - t.Fatal("an idle agent answered busy") - } - if err := drainedTooLong(time.Now()); err == nil { - t.Fatal("an ask started the drain again") - } - // The asks stopped: the drain lapsed, and so did the fault. - old := time.Now().Add(-3 * time.Minute) - if err := os.Chtimes(drainMark(), old, old); err != nil { - t.Fatal(err) - } - if err := drainedTooLong(time.Now()); err != nil { - t.Fatalf("a lapsed drain still reads not ready: %v", err) - } -} - -// An ask this build does not know exits non-zero: an older image asked `busy` reads idle, not busy for the -// node-engine's whole bound. -func TestAnUnknownCommandFails(t *testing.T) { - was := os.Args - t.Cleanup(func() { os.Args = was }) - os.Args = []string{"mesh-builder", "some-later-word"} - if err := run(); err == nil { - t.Fatal("an unknown command exited 0") - } - os.Args = []string{"mesh-builder", "help"} - if err := run(); err != nil { - t.Fatalf("help failed: %v", err) - } -} - -// 1768/AC2: a mark naming no build of this agent (left by a crash or a fault) is stale: the next ask removes it and is -// run, never fetched and handed back in a loop. -func TestAStaleInHandMarkIsRemovedAndTheAskRuns(t *testing.T) { - t.Setenv("TMPDIR", t.TempDir()) - inHand.Store(false) - if err := undrained(); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(inHandMark(), []byte("2026-10-21T18:45:06Z\n"), 0o644); err != nil { - t.Fatal(err) - } - if !claim() { - t.Fatal("a stale in-hand mark made the agent hand back an ask") - } - // A live one — this agent's own build in hand — is never taken over. - if claim() { - t.Fatal("a second build was taken while one is in hand") - } - if _, err := busy(); err != nil { - t.Fatalf("the build in hand was lost: %v", err) - } - released() -} - -// 1768/AC3: an agent that cannot mark a build in hand for another reason takes no work, instead of fetching asks and -// handing them back; it takes work again once marking works. -func TestAnAgentThatCannotMarkABuildTakesNoWorkUntilItCan(t *testing.T) { - dir := t.TempDir() - t.Setenv("TMPDIR", dir) - inHand.Store(false) - markFault, markSaid = time.Time{}, "" - t.Cleanup(func() { markFault, markSaid = time.Time{}, "" }) - if err := undrained(); err != nil { - t.Fatal(err) - } - // The mark's place is a directory that cannot be removed or made over: a directory named as the mark. - if err := os.MkdirAll(inHandMark()+"/x", 0o755); err != nil { - t.Fatal(err) - } - if claim() { - t.Fatal("an agent that cannot mark a build took it") - } - if !markFaulty(time.Now()) { - t.Fatal("an agent that cannot mark a build still takes work") - } - // Still failing after the retry time: still takes nothing, and no ask was needed to find out. - if !markFaulty(time.Now().Add(2 * markRetry)) { - t.Fatal("an agent whose mark still cannot be made takes work") - } - if err := os.RemoveAll(inHandMark()); err != nil { - t.Fatal(err) - } - if markFaulty(time.Now().Add(4 * markRetry)) { - t.Fatal("an agent whose mark can be made again takes no work") - } - if !claim() { - t.Fatal("an agent that can mark takes no build") - } - released() -} - -// 1834/AC4: given a node under the floor of free space, when the taking loop asks, then it takes nothing, and takes again -// once there is room. -func TestANodeUnderTheFloorOfFreeSpaceTakesNoWork(t *testing.T) { - was := builder.FreeBytes - t.Cleanup(func() { builder.FreeBytes = was }) - builder.FreeBytes = func(string) (uint64, error) { return 2 << 30, nil } - if !noRoom(t.TempDir()) { - t.Error("a node with 2 GiB free took work") - } - builder.FreeBytes = func(string) (uint64, error) { return 200 << 30, nil } - if noRoom(t.TempDir()) { - t.Error("a node with 200 GiB free refused work") - } -} diff --git a/cmd/mesh-builder/check_spec_test.go b/cmd/mesh-builder/check_spec_test.go deleted file mode 100644 index 808c32b9..00000000 --- a/cmd/mesh-builder/check_spec_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "testing" - - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/link" -) - -// The build seat hands the check the judge cache the build agent declares (MESH_JUDGE_CACHE), never a path of its own. -func TestTheBuildSeatHandsTheCheckTheJudgeCacheItDeclares(t *testing.T) { - 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) - } -} - -// The siblings a check is asked to read reach the build seat's spec and, as read, the outcome (novox/hq ADR 0382). -func TestTheSiblingsAnAskNamesReachTheSpecAndTheOutcome(t *testing.T) { - spec := checkSpecOf(link.BuildRequest{Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Base: "main", - Siblings: []link.CheckSibling{{Repository: "novox/mesh-controller", Number: 346, Head: "a", Base: "main", Dir: "mesh-controller"}}, - SiblingsUnknown: "why"}}) - if len(spec.Siblings) != 1 || spec.Siblings[0] != (builder.Sibling{Repository: "novox/mesh-controller", Number: 346, - Head: "a", Base: "main", Dir: "mesh-controller"}) || spec.SiblingsUnknown != "why" { - t.Fatalf("the spec reads %+v", spec) - } - out := siblingsOut([]builder.JudgedSibling{{Repository: "novox/mesh-controller", Number: 346, Head: "a", Base: "main", BaseCommit: "b"}}) - if len(out) != 1 || out[0].BaseCommit != "b" || out[0].Number != 346 { - t.Fatalf("the outcome reads %+v", out) - } - if plain := checkSpecOf(link.BuildRequest{Check: &link.CheckRequest{Owner: "o", Repo: "r"}}); plain.Siblings != nil { - t.Fatalf("a request that names none has siblings %+v", plain.Siblings) - } -} - -// 1826/AC2: the held values a check request carries reach the spec; a request that carries none leaves it empty. -func TestTheHeldValuesAnAskCarriesReachTheSpec(t *testing.T) { - spec := checkSpecOf(link.BuildRequest{Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", - HeldConfig: []link.HeldConfig{{Pull: "novox/mesh-catalog#434", Head: "a", Node: "ace", Module: "n8n", Key: "media-gid", - Value: []byte("2000"), Approved: "the operator"}}}}) - if len(spec.Held) != 1 || spec.Held[0].Node != "ace" || spec.Held[0].Key != "media-gid" || string(spec.Held[0].Value) != "2000" || - spec.Held[0].Approved != "the operator" { - t.Fatalf("the spec reads %+v", spec.Held) - } - if plain := checkSpecOf(link.BuildRequest{Check: &link.CheckRequest{Owner: "o", Repo: "r"}}); plain.Held != nil { - t.Fatalf("a request that carries none has %+v", plain.Held) - } -} diff --git a/cmd/mesh-builder/holder.go b/cmd/mesh-builder/holder.go deleted file mode 100644 index ff962c44..00000000 --- a/cmd/mesh-builder/holder.go +++ /dev/null @@ -1,433 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/nats-io/nats.go/micro" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/link" -) - -// What a holder of the build seat answers for, on its own machine (novox/hq ADR 0219). -// -// **The queue is the controller's; the build running here is this machine's.** The controller can -// see and change what waits in the seat's queue, but an ask a holder already took is a process tree -// on this machine and containers in this machine's runtime, and only this machine can end them. So -// the holder serves four verbs on the seat's subjects for this machine: what it is building, kill -// it, pause, resume. -// -// **One build at a time** (ADR 0190), which is also what builder.Said assumes — a package global -// set per build — so "the build running here" is one or none, and kill names it by id so a call -// that arrives as one build ends and the next begins cannot end the wrong one. - -// holder is this machine's state as a holder of the build seat. -type holder struct { - on, seat string - // workspace is where the paused flag is kept, so a holder restarted while paused stays paused - // rather than silently taking work again. - workspace string - // say publishes this machine's state: whether it takes work (link.HolderState). - say func(link.HolderState) error - // remove runs what a kill needs outside the build: the containers left behind. - remove builder.Runner - // scratch is where check builds are kept (novox/hq ADR 0350 §5): what the week's sweep lets go of. Nil where - // this machine publishes to no artifact store. - scratch builder.Scratch - - mu sync.Mutex - paused bool - running *running -} - -// running is the build this machine is doing. -type running struct { - request link.BuildRequest - step string - started time.Time - cancel context.CancelFunc - killed bool - // returned is the build's own work having ended, before a kill or not; outcome is what was then - // announced, and announced whether it went out. - returned bool - outcome string - announced bool - done chan struct{} -} - -// pausedFile is where the flag lives in the workspace. -func pausedFile(workspace string) string { return filepath.Join(workspace, ".mesh-builder-paused") } - -// newHolder reads the paused flag the workspace keeps. -func newHolder(on, seat, workspace string, say func(link.HolderState) error) *holder { - h := &holder{on: on, seat: seat, workspace: workspace, say: say, remove: plainRun} - if _, err := os.Stat(pausedFile(workspace)); err == nil { - h.paused = true - } - return h -} - -// Paused is asked by the taking loop before every fetch. -func (h *holder) Paused() bool { - h.mu.Lock() - defer h.mu.Unlock() - return h.paused -} - -// setPaused records the flag in the workspace first and then in memory, so what this holder says -// it is and what it would be after a restart never differ. -func (h *holder) setPaused(paused bool) error { - path := pausedFile(h.workspace) - if paused { - if err := os.MkdirAll(h.workspace, 0o755); err != nil { - return err - } - if err := os.WriteFile(path, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil { - return fmt.Errorf("cannot keep the paused flag in %s: %w", path, err) - } - } else if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("cannot remove the paused flag %s: %w", path, err) - } - h.mu.Lock() - h.paused = paused - h.mu.Unlock() - h.announce() - return nil -} - -// announce says whether this machine takes work. Never fatal: the flag is kept either way, and the -// controller reading an older state is a plan read as late rather than a build lost. -func (h *holder) announce() { - if h.say == nil { - return - } - if err := h.say(link.HolderState{On: h.on, Paused: h.Paused(), At: time.Now().UTC().Format(time.RFC3339Nano)}); err != nil { - fmt.Fprintf(os.Stderr, "cannot say whether this machine takes builds: %v\n", err) - } -} - -// stateWhilePaused says this machine's state at start, and again every while it stays paused, so -// a pause outlives the events stream's retention. -func (h *holder) stateWhilePaused(ctx context.Context, every time.Duration) { - h.announce() - tick := time.NewTicker(every) - defer tick.Stop() - for { - select { - case <-ctx.Done(): - return - case <-tick.C: - if h.Paused() { - h.announce() - } - } - } -} - -// expireChecks lets go of every check build older than a week (novox/hq ADR 0350 §5), at start and every while -// after. Every holder sweeps the one store; what one let go of, another finds gone. -func (h *holder) expireChecks(ctx context.Context, every time.Duration, now func() time.Time) { - sweep := func() { - if h.scratch == nil { - return - } - n, err := h.scratch.Expire(ctx, builder.CheckBuildsKept, now()) - switch { - case err != nil: - fmt.Fprintf(os.Stderr, "check builds older than %s: %d let go of, and not all: %v\n", builder.CheckBuildsKept, n, err) - case n > 0: - fmt.Fprintf(os.Stderr, "check builds older than %s: %d check(s)' builds let go of\n", builder.CheckBuildsKept, n) - } - } - sweep() - tick := time.NewTicker(every) - defer tick.Stop() - for { - select { - case <-ctx.Done(): - return - case <-tick.C: - sweep() - } - } -} - -// begin records the build this machine took, with the cancel that ends it. -func (h *holder) begin(request link.BuildRequest, cancel context.CancelFunc) *running { - r := &running{request: request, started: time.Now(), cancel: cancel, done: make(chan struct{})} - h.mu.Lock() - h.running = r - h.mu.Unlock() - return r -} - -// end clears it, and lets a kill waiting on it know it is over. -func (h *holder) end(r *running) { - h.mu.Lock() - if h.running == r { - h.running = nil - } - h.mu.Unlock() - close(r.done) -} - -// stepped records the step a build is at, for `current`. -func (h *holder) stepped(r *running, step string) { - h.mu.Lock() - r.step = step - h.mu.Unlock() -} - -// currentBuild is what `current` answers. -type currentBuild struct { - On string `json:"on"` - Paused bool `json:"paused"` - Running *struct { - ID string `json:"id"` - Repository string `json:"repository"` - Path string `json:"path,omitempty"` - Ref string `json:"ref,omitempty"` - Step string `json:"step,omitempty"` - Started string `json:"started"` - Elapsed string `json:"elapsed"` - } `json:"running,omitempty"` - Said string `json:"said"` -} - -func (h *holder) current() currentBuild { - h.mu.Lock() - defer h.mu.Unlock() - out := currentBuild{On: h.on, Paused: h.paused} - taking := "taking builds" - if h.paused { - taking = "paused, taking no new build" - } - if h.running == nil { - out.Said = fmt.Sprintf("%s is building nothing; %s", h.on, taking) - return out - } - r := h.running - elapsed := time.Since(r.started).Round(time.Second) - out.Running = &struct { - ID string `json:"id"` - Repository string `json:"repository"` - Path string `json:"path,omitempty"` - Ref string `json:"ref,omitempty"` - Step string `json:"step,omitempty"` - Started string `json:"started"` - Elapsed string `json:"elapsed"` - }{r.request.ID, r.request.Repository, r.request.Path, r.request.Ref, r.step, - r.started.UTC().Format(time.RFC3339), elapsed.String()} - out.Said = fmt.Sprintf("%s is building %s (%s) at %s for %s; %s", - h.on, r.request.Repository, r.request.ID, orNothing(r.step), elapsed, taking) - return out -} - -// The bounds a kill keeps: each pass removing containers, and the wait for the build to end between -// them. The worst case — 15s, 20s, 15s — is inside what the controller waits for the answer -// (killAnswer in the controller's queue.go, 75s). -var ( - killRemoves = 15 * time.Second - killWaits = 20 * time.Second -) - -// kill ends the build with this id, if it is the one running here and still working: its context -// cancelled — which kills each command's process group — and the containers it started removed by -// their label, once at once and again after the build has ended, so one created while it was being -// killed is not left. The build's own goroutine announces it failed, killed by hand, and settles the -// ask so it is not redelivered; the answer says whether that happened. -func (h *holder) kill(id string) (string, error) { - h.mu.Lock() - r := h.running - if r == nil || r.request.ID != id { - doing := "nothing" - if r != nil { - doing = r.request.ID - } - h.mu.Unlock() - return "", fmt.Errorf("%s is not building %s; it is building %s. `queue` says where an ask is", h.on, id, doing) - } - if r.returned { - h.mu.Unlock() - return "", fmt.Errorf("%s on %s has already ended on its own and is saying how; `builds` shows it", id, h.on) - } - r.killed = true - cancel, done := r.cancel, r.done - h.mu.Unlock() - - cancel() - removed, removeErr := h.removeContainers(id) - ended := false - select { - case <-done: - ended = true - case <-time.After(killWaits): - } - again, againErr := h.removeContainers(id) - removed += again - if removeErr == nil { - removeErr = againErr - } - containers := fmt.Sprintf("%d container(s) it started removed", removed) - if removeErr != nil { - containers = "its containers could not all be listed or removed: " + removeErr.Error() - } - if !ended { - return fmt.Sprintf("killed %s on %s: %s; the build has not finished ending yet — `builds` says when "+ - "its outcome is in", id, h.on, containers), nil - } - h.mu.Lock() - outcome, announced := r.outcome, r.announced - h.mu.Unlock() - if !announced { - return fmt.Sprintf("killed %s (%s) on %s: its commands ended and %s, and its outcome could not be "+ - "announced — the ask is not settled and will be handed out again", id, r.request.Repository, h.on, - containers), nil - } - return fmt.Sprintf("killed %s (%s) on %s: its commands ended, %s, and its outcome announced as failed, %s — "+ - "settled, so it is not handed to another machine", id, r.request.Repository, h.on, containers, outcome), nil -} - -// removeContainers is one pass of removing what the build left, bounded. -func (h *holder) removeContainers(id string) (int, error) { - cleanup, stop := context.WithTimeout(context.Background(), killRemoves) - defer stop() - return builder.RemoveContainersOf(cleanup, h.remove, id) -} - -// removeLeftBehind removes, once, what earlier holders on this machine left — called before anything is -// taken, so none of it is this holder's — and says what it did. A runtime that cannot be asked is said -// and the holder takes work all the same: what is left is waste, not a reason to stop building. -func (h *holder) removeLeftBehind() int { - cleanup, stop := context.WithTimeout(context.Background(), killRemoves) - defer stop() - n, err := builder.RemoveLeftBehind(cleanup, h.remove) - switch { - case err != nil: - fmt.Fprintf(os.Stderr, "could not look for containers earlier builds left on %s: %v\n", h.on, err) - case n > 0: - fmt.Fprintf(os.Stderr, "removed %d container(s) earlier builds left on %s\n", n, h.on) - } - return n -} - -// returned records that the build's work ended, and says whether a kill came first — only then is -// the build killed; an error it ended with on its own is its own outcome. -func (h *holder) returned(r *running) bool { - h.mu.Lock() - defer h.mu.Unlock() - r.returned = true - return r.killed -} - -// said records the outcome announced, and whether it went out, for a kill to answer with. -func (h *holder) said(r *running, outcome string, announced bool) { - h.mu.Lock() - r.outcome, r.announced = outcome, announced - h.mu.Unlock() -} - -// handlers are the seat's verbs, as this machine answers them. -func (h *holder) handlers() map[string]link.ToolHandler { - return map[string]link.ToolHandler{ - "current": func(context.Context, json.RawMessage) (any, error) { return h.current(), nil }, - "kill": func(_ context.Context, raw json.RawMessage) (any, error) { - var args struct { - ID string `json:"id"` - } - if err := json.Unmarshal(raw, &args); err != nil || strings.TrimSpace(args.ID) == "" { - return nil, errors.New("kill needs the build's id") - } - said, err := h.kill(strings.TrimSpace(args.ID)) - if err != nil { - return nil, err - } - return map[string]any{"said": said}, nil - }, - "pause": func(context.Context, json.RawMessage) (any, error) { - if err := h.setPaused(true); err != nil { - return nil, err - } - said := h.on + " is paused: it takes no new build until resumed" - if c := h.current(); c.Running != nil { - said += "; " + c.Running.ID + " runs on and finishes" - } - return map[string]any{"said": said, "paused": true}, nil - }, - "resume": func(context.Context, json.RawMessage) (any, error) { - if err := h.setPaused(false); err != nil { - return nil, err - } - return map[string]any{"said": h.on + " takes builds again", "paused": false}, nil - }, - } -} - -func orNothing(s string) string { - if s == "" { - return "its start" - } - return s -} - -// plainRun runs a command outside any build: no line reaches a build's log, because the build whose -// log it would be is the one being ended. -func plainRun(ctx context.Context, dir, name string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, name, args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - return string(out), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) - } - return string(out), nil -} - -// announcement is what this holder answers discovery with (novox/hq ADR 0195, ADR 0197): this -// machine's verbs of the seat, in the shape every tool runtime announces a seat's verb — kind seat, -// the module answering, the seat, scope node, the machine, its description and argument schema — so -// the console finds `/node-build-agent.kill` by searching, as it finds any seat's verb. -// -// One service per machine, named for the seat and identified by the machine, so the answer is this -// machine's four verbs and nothing more. The verbs are the compiled seat row's: a build machine has no -// store, and what it serves is what this binary was built to serve. -func announcement(seat, module, node string) micro.Info { - s, _ := catalogue.SeatNamed(seat) - var endpoints []micro.EndpointInfo - for _, v := range s.Serves { - schema, _ := json.Marshal(v.Input) - endpoints = append(endpoints, micro.EndpointInfo{ - Name: seat + "__" + v.Name, - Subject: link.NodeSeatToolSubject(seat, v.Name, node), - // The queue group the verbs are served in, as every runtime announces its own. - QueueGroup: "seat." + seat, - Metadata: map[string]string{ - "kind": "seat", "module": module, "tool": v.Name, "seat": seat, "scope": "node", - "node": node, "interchangeable": "false", "description": v.Description, "schema": string(schema), - }, - }) - } - return micro.Info{ - ServiceIdentity: micro.ServiceIdentity{Name: seat, ID: node, Version: "0.1.0", - Metadata: map[string]string{"seat": seat, "scope": "node", "node": node, "module": module}}, - Description: "what the build running on " + node + " is, and ending, pausing and resuming it (novox/hq ADR 0219)", - Endpoints: endpoints, - } -} - -// moduleOf is the module a credential was issued for: its user is `.`. -func moduleOf(user string) string { - if _, module, ok := strings.Cut(user, "."); ok && module != "" { - return module - } - return "build-agent" -} diff --git a/cmd/mesh-builder/holder_test.go b/cmd/mesh-builder/holder_test.go deleted file mode 100644 index c89c6b3d..00000000 --- a/cmd/mesh-builder/holder_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -// A holder paused stays paused across its restart: the flag is kept in its workspace (novox/hq ADR -// 0219), and what it says about itself follows. -func TestAPausedHolderStaysPausedAcrossARestart(t *testing.T) { - workspace := t.TempDir() - var said []link.HolderState - h := newHolder("ace", link.TheBuildMachine, workspace, func(s link.HolderState) error { - said = append(said, s) - return nil - }) - if h.Paused() { - t.Fatal("a new holder starts paused") - } - if _, err := h.handlers()["pause"](context.Background(), json.RawMessage(`{}`)); err != nil { - t.Fatal(err) - } - if !h.Paused() || len(said) != 1 || !said[0].Paused || said[0].On != "ace" { - t.Fatalf("paused: %v, said %+v", h.Paused(), said) - } - again := newHolder("ace", link.TheBuildMachine, workspace, nil) - if !again.Paused() { - t.Fatal("restarted, the holder forgot it was paused") - } - if _, err := again.handlers()["resume"](context.Background(), json.RawMessage(`{}`)); err != nil { - t.Fatal(err) - } - if newHolder("ace", link.TheBuildMachine, workspace, nil).Paused() { - t.Fatal("resumed, the holder came back paused") - } -} - -// kill ends the build with that id — its context, which ends its commands — removes what it left by -// label, and refuses an id it is not building. -func TestKillEndsTheBuildRunningHereAndNoOther(t *testing.T) { - h := newHolder("ace", link.TheBuildMachine, t.TempDir(), nil) - var removed []string - h.remove = func(_ context.Context, _ string, name string, args ...string) (string, error) { - removed = append(removed, name+" "+strings.Join(args, " ")) - if args[0] == "ps" { - return "c1\n", nil - } - return "", nil - } - kill := h.handlers()["kill"] - if _, err := kill(context.Background(), json.RawMessage(`{"id":"build-1"}`)); err == nil { - t.Fatal("killed a build while none ran") - } - - building, cancel := context.WithCancel(context.Background()) - r := h.begin(link.BuildRequest{ID: "build-1", Repository: "novox/a"}, cancel) - h.stepped(r, "image") - if c := h.current(); c.Running == nil || c.Running.ID != "build-1" || c.Running.Step != "image" { - t.Fatalf("current says %+v", c) - } - if _, err := kill(context.Background(), json.RawMessage(`{"id":"build-2"}`)); err == nil || - !strings.Contains(err.Error(), "build-1") { - t.Fatalf("killed another id: %v", err) - } - // The build's own goroutine: it ends when its context does, as a build's commands do, and - // announces what came of it. - go func() { - <-building.Done() - if h.returned(r) { - h.said(r, link.KilledByHand, true) - } - h.end(r) - }() - answer, err := kill(context.Background(), json.RawMessage(`{"id":"build-1"}`)) - if err != nil { - t.Fatal(err) - } - if building.Err() == nil { - t.Fatal("the build's context was not cancelled") - } - if !r.killed { - t.Error("the build is not marked killed, so it would be announced as an ordinary failure") - } - said := answer.(map[string]any)["said"].(string) - if !strings.Contains(said, "2 container(s)") || !strings.Contains(said, "announced as failed") || !strings.Contains(said, link.KilledByHand) { - t.Errorf("kill said %q", said) - } - // Removed at the kill and again once the build had ended: a container made in between is caught. - if len(removed) != 6 || !strings.Contains(removed[0], "label=mesh.build=build-1") || !strings.Contains(removed[3], "label=mesh.build=build-1") { - t.Errorf("removed %v", removed) - } - if c := h.current(); c.Running != nil { - t.Errorf("after the kill current says %+v", c) - } -} - -// A holder announces this machine's verbs of the seat as the console reads a seat's verb, and no more. -func TestAHolderAnnouncesItsMachinesVerbsForTheConsole(t *testing.T) { - info := announcement(link.TheBuildMachine, moduleOf("ace.build-agent"), "ace") - if info.Name != "node-build-agent" || info.ID != "ace" || len(info.Endpoints) != 4 { - t.Fatalf("announced %s/%s with %d endpoints", info.Name, info.ID, len(info.Endpoints)) - } - kill := info.Endpoints[1] - md := kill.Metadata - if kill.Subject != "mesh.seat.node-build-agent.tool.kill.ace" || kill.QueueGroup != "seat.node-build-agent" || md["kind"] != "seat" || md["seat"] != "node-build-agent" || - md["scope"] != "node" || md["node"] != "ace" || md["tool"] != "kill" || md["module"] != "build-agent" || - !strings.Contains(md["description"], "killed by hand") || !strings.Contains(md["schema"], `"id"`) { - t.Fatalf("kill is announced as %+v", kill) - } - body, err := json.Marshal(info) - if err != nil || len(body) > 8*1024 { - t.Fatalf("the answer is %d bytes (%v)", len(body), err) - } -} - -// A build that ended on its own as the kill arrived says what it did: the kill is refused, and its -// own error is its outcome. One whose outcome could not be announced is not said to be settled. -func TestAKillArrivingAfterTheBuildEndedIsRefusedAndAnUnannouncedKillSaysSo(t *testing.T) { - h := newHolder("ace", link.TheBuildMachine, t.TempDir(), nil) - h.remove = func(context.Context, string, string, ...string) (string, error) { return "", nil } - _, cancel := context.WithCancel(context.Background()) - r := h.begin(link.BuildRequest{ID: "build-1"}, cancel) - if killed := h.returned(r); killed { - t.Fatal("a build nobody killed reads as killed") - } - if _, err := h.kill("build-1"); err == nil || !strings.Contains(err.Error(), "ended on its own") { - t.Fatalf("killed a build that had ended: %v", err) - } - h.end(r) - - building, cancel := context.WithCancel(context.Background()) - r = h.begin(link.BuildRequest{ID: "build-2"}, cancel) - go func() { - <-building.Done() - if h.returned(r) { - h.said(r, link.KilledByHand, false) - } - h.end(r) - }() - said, err := h.kill("build-2") - if err != nil || strings.Contains(said, "announced as failed") || !strings.Contains(said, "could not be announced") { - t.Fatalf("kill said %q (%v)", said, err) - } -} - -// **Issue 306**: a holder starting removes the containers earlier builds left on its machine, through -// the runner a kill uses, and a runtime that cannot be asked does not stop it. -func TestAHolderStartingRemovesWhatEarlierBuildsLeftHere(t *testing.T) { - h := newHolder("novox", link.TheBuildMachine, t.TempDir(), nil) - var removed []string - h.remove = func(_ context.Context, _ string, name string, args ...string) (string, error) { - removed = append(removed, name+" "+strings.Join(args, " ")) - if args[0] == "ps" { - return "c1 build-1\nc2 build-1\nc3 check-here-2\n", nil - } - return "", nil - } - if n := h.removeLeftBehind(); n != 2 || len(removed) != 3 || removed[2] != "docker rm -f -v c1 c2" { - t.Fatalf("removed %d: %v", n, removed) - } - h.remove = func(context.Context, string, string, ...string) (string, error) { - return "", errors.New("no runtime") - } - if n := h.removeLeftBehind(); n != 0 { - t.Errorf("a runtime that cannot be asked removed %d", n) - } -} diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go deleted file mode 100644 index 2945e462..00000000 --- a/cmd/mesh-builder/main.go +++ /dev/null @@ -1,946 +0,0 @@ -// mesh-builder — the thing a build machine runs. -// -// It takes work from the mesh, turns a repository into artifacts, publishes them, and says what -// came out. It is **not** the control plane and it is **not** the host: -// -// - the control plane decides and never touches a machine. Building runs commands on one, and -// what the control plane may send a machine is bounded by the declaration language -// (novox/hq ADR 0005). "Run this build" is not in it, and widening the language so it could -// be would make the control plane able to run anything anywhere. -// - the host applies declarations and holds no opinion about what they contain. A host that -// also built things would need a container runtime and git, on every machine, to do something -// almost none of them will ever do. -// -// So it is a module: a program a machine runs because the mesh told it to, holding its own broker -// credential and nothing else. Compromise of a build machine is compromise of a build machine. -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log" - "net/url" - "os" - "os/signal" - "sort" - "strings" - "sync" - "sync/atomic" - "syscall" - "time" - - "github.com/nats-io/nats.go" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/link" -) - -// version is set at build time. -var version = "development" - -func main() { - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "mesh-builder: %v\n", err) - os.Exit(1) - } -} - -const usage = `mesh-builder — builds modules for the mesh - -It consumes build requests and answers with what it made. Nothing is listened on and nothing -is dialled except the broker. - - MESH_BROKER_FILE a file the mesh sealed to this machine holding the same - MESH_REGISTRY host:port to publish artifacts to, when the mesh has not said - MESH_BINDING a file the mesh wrote saying where the artifact store is - MESH_PACKAGE_BINDING a file the mesh wrote saying where the package registry is - MESH_NPM_TOKEN_FILE a file the mesh sealed holding the token for it - MESH_GENESIS_PACKAGE_REGISTRY the document the installer sealed at genesis, read while no - binding is present (novox/hq ADR 0336) - MESH_NPM_REGISTRY a package registry URL, when the mesh has not said (a person, the bootstrap) - MESH_NPM_TOKEN the token for it, likewise - MESH_NPM_SCOPE the scope it answers for (default: @novox) - MESH_WORKSPACE where to clone and build (default: a temporary directory) - MESH_BUILD_CGROUP_PARENT the slice every container a build or check starts joins, which carries the - machine's limits on builds (novox/hq issue 1798; default: the runtime's own) - -It also builds one module and stops, which is how a mesh is raised — before there is a -broker to take work from or a registry to publish into: - - mesh-builder build [--path P] [--ref COMMIT] [--registry HOST:PORT] - -Without --registry the artifacts stay in this machine's container runtime, named by the -digest of their own configuration. The result is printed as JSON. - - mesh-builder ready - -exits 0 once the agent running in this container is bound to its build work and pulls it, and -1 before: its container's health check. - - mesh-builder busy - -exits 0 while the agent running in this container has a build in hand, and 1 when it has none; -from the first ask the agent takes no new build: its container's busy command, asked before the -container is replaced (novox/hq ADR 0335). -` - -func run() error { - if len(os.Args) > 1 { - switch os.Args[1] { - case "version": - fmt.Println(version) - return nil - case "build": - return buildOnce(context.Background(), os.Args[2:]) - case "ready": - // The container's health check (novox/hq issue 544): exits non-zero until the agent takes work, and - // once it has been drained longer than its node-engine would wait to replace it (ADR 0335). - if err := ready(); err != nil { - return err - } - return drainedTooLong(time.Now()) - case "busy": - // The container's busy command (novox/hq ADR 0335): drains the agent, and exits non-zero once - // nothing is in hand. - said, err := busy() - if err != nil { - return err - } - fmt.Println(said) - return nil - case "help", "-h", "--help": - fmt.Print(usage) - return nil - default: - // **An ask this build does not know fails** (novox/hq ADR 0335): an image older than `busy`, asked - // it, must read as idle — exiting 0 would hold its container for the node-engine's whole bound. - fmt.Fprint(os.Stderr, usage) - return fmt.Errorf("%q is not a command this mesh-builder knows", os.Args[1]) - } - } - - if err := notReady(); err != nil { - return err - } - // **The builder's own version is part of every reuse key** (novox/hq ADR 0350 §1): a builder that builds - // differently never takes a build another one made. - builder.Version = builder.SelfVersion(version) - if err := undrained(); err != nil { - return err - } - credential, err := brokerFrom() - if err != nil { - return err - } - registry, err := whereToPublish() - if err != nil { - return err - } - // A build and a check run below the machine's own work (novox/hq issue 1798): the agent itself at the - // lowest priority, which its git and go children inherit, and every container in the build slice. - _ = syscall.Setpriority(syscall.PRIO_PROCESS, 0, 19) - builder.CgroupParent = strings.TrimSpace(os.Getenv("MESH_BUILD_CGROUP_PARENT")) - workspace := os.Getenv("MESH_WORKSPACE") - if workspace == "" { - workspace = os.TempDir() + "/mesh-builder" - } - // **The mesh's name for this machine, not the container's.** A build is reported to the rest - // of the mesh, and a report whose origin reads `104cb10e105b` names something no other module - // can look up. The mesh already knows the answer and has a way to say it — `${machine:name}` - // in the environment file this module is handed — so the hostname is only what is left when - // nobody said. - on := os.Getenv("MESH_NODE") - if on == "" { - hostname, err := os.Hostname() - if err != nil { - return fmt.Errorf("this build machine has no name: nothing said MESH_NODE and the host would not say either: %w", err) - } - on = hostname - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - js, seat, err := dialFor(credential) - if err != nil { - return err - } - defer js.Close() - - // **This machine's holder: paused or not, and the build it is running** (novox/hq ADR 0219). The - // paused flag is read from the workspace before anything is taken, so a holder restarted while - // paused takes nothing. - h := newHolder(on, seat, workspace, func(state link.HolderState) error { - body, err := json.Marshal(state) - if err != nil { - return err - } - _, err = js.Context().Publish(link.BuildPausedOf(seat, on), body) - return err - }) - if h.Paused() { - fmt.Fprintf(os.Stderr, "paused (kept in %s): taking no build until resumed\n", pausedFile(workspace)) - } - // **What an earlier holder here left goes before anything is taken** (novox/hq issue 306): a holder - // recreated mid-check left the check's store and bus running, and the ask went to another machine. - h.removeLeftBehind() - // Paused or drained, it takes nothing new (novox/hq ADR 0219, ADR 0335): a drained agent finishes the build - // in hand and then says idle, and its container is replaced between builds. - machine := link.MachineOverNATSWith(js, on, seat, link.MachineOptions{ - Paused: func() bool { return h.Paused() || drained() || markFaulty(time.Now()) || noRoom(workspace) }, - Taking: func() { taking(seat) }, - Claim: claim, - }) - defer machine.Close() - - // The seat's verbs, on this machine's subjects. Only the seat that declares them: the retired - // one serves none, and a subscription its holder has no grant for would be refused for ever. - if seat == link.TheBuildMachine { - stopServing, err := link.OverNATS{Conn: js.Conn()}.ServeNodeSeatTools(seat, on, h.handlers(), - log.New(os.Stderr, "", 0)) - if err != nil { - return err - } - defer stopServing() - // And says so, for the console to find (novox/hq ADR 0197). - stopAnnouncing, err := link.OverNATS{Conn: js.Conn()}.Announce( - announcement(seat, moduleOf(credential.User), on), log.New(os.Stderr, "", 0)) - if err != nil { - return err - } - defer stopAnnouncing() - go h.stateWhilePaused(ctx, time.Hour) - } - - fmt.Fprintf(os.Stderr, "building for the mesh, publishing to %s\n", registry) - publisher := builder.Registry{Address: registry, Run: builder.Command} - // Check builds are let go of after a week (novox/hq ADR 0350 §5). - h.scratch = publisher - go h.expireChecks(ctx, time.Hour, time.Now) - - return machine.Take(ctx, func(ctx context.Context, work link.Build) { - defer released() - holding(work.Request().Repository + " (" + work.Request().ID + ")") - answer(ctx, publisher, on, workspace, work, h) - }) -} - -// dialFor opens this machine's link to whichever bus the mesh is on, and says which build seat it -// holds. -// -// **One place chooses**, as everywhere else the bus change went (novox/hq ADR 0116 step 5): a build -// machine told about both would take work from one and answer on the other, and every log line would -// say it was fine. -func dialFor(credential Credential) (*broker.JetStream, string, error) { - // **The credential names the bus, and there is one** (novox/hq ADR 0131, design 28 task 5.5). - // A credential for the mesh's bus carries user, password and fingerprint beside the address, - // and that is enough to dial it, pinned. - if !credential.onTheNewBus() { - return nil, "", fmt.Errorf("the credential at hand names %q, which is not the mesh's bus", credential.URL) - } - // Named for what it is, not the controller whose code dials it (novox/hq issue 327). - host, _ := os.Hostname() - js, err := broker.DialPinned(credential.natsURL(), credential.Fingerprint, nats.Name("build agent on "+host)) - if err != nil { - return nil, "", err - } - // **The seat this machine serves is the one its credential claims** (novox/hq ADR 0190, the - // handover): the mesh issues a build machine's credential naming the seat its module claims, - // and one binary serves the old role as `builder` and the new as `build-agent` from that alone. - seat := link.BuildSeatClaimed(credential.seatsClaimed()) - fmt.Fprintf(os.Stderr, "taking build work as a holder of %s\n", seat) - return js, seat, nil -} - -// answer does one build and says what happened, whichever way it went. -func answer(ctx context.Context, publisher builder.Publisher, on, workspace string, work link.Build, h *holder) { - request := work.Request() - - // **Its own context, so it can be killed alone** (novox/hq ADR 0219): cancelled by `kill`, it ends - // this build's commands and nothing else; the machine's own context ending — a SIGTERM — still - // reaches it through the parent, and that keeps today's meaning below. - building, cancel := context.WithCancel(ctx) - defer cancel() - var mine *running - if h != nil { - mine = h.begin(request, cancel) - defer h.end(mine) - } - - // **First thing, and to stdout.** A build request that arrives and produces no visible line until - // it either finishes or fails is indistinguishable from one that never arrived — which cost a long - // diagnosis against a running mesh, chasing "the handler never fired" when the truth was only that - // the handler said nothing until the end. - fmt.Fprintf(os.Stderr, "a build request arrived for %s (%s)\n", request.Repository, request.ID) - - // **Everything a build says goes two ways**: to stderr, as always, and onto the bus as the - // 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" { - h.stepped(mine, step) - } - } - builder.Said = say - defer func() { builder.Said = nil }() - if err := work.Began(ctx); err != nil { - fmt.Fprintf(os.Stderr, "cannot say a build started: %v\n", err) - } - // **A running check watches its pull request's key** (novox/hq ADR 0406 §4, issue 974): when the head or the trunk - // commit in it moves, the check is for a state nobody wants any more, and ends itself — nothing is asked of the forge - // (issue 726). A group's check, a try and a build watch nothing. - var superseded atomic.Bool - if w, ok := work.(interface { - WatchPull(context.Context, func(link.PullKey)) (func(), error) - }); ok && request.Check != nil { - stopWatching, err := w.WatchPull(building, func(k link.PullKey) { - superseded.Store(true) - say("superseded", fmt.Sprintf("the pull request's key moved to %.12s on %.12s while this checked %.12s on %.12s: stopped", - k.Head, k.Trunk, request.Ref, request.Check.Trunk)) - cancel() - }) - if err != nil { - fmt.Fprintf(os.Stderr, "the key of %s/%s#%d could not be watched, so its check runs on if the key moves: %v\n", - request.Check.Owner, request.Check.Repo, request.Check.Number, err) - } else { - defer stopWatching() - } - } - - result := link.BuildResult{ - ID: request.ID, Repository: request.Repository, Path: request.Path, - Ref: request.Ref, On: on, Source: request.Source, DryRun: request.DryRun, - } - what := "building " + request.Repository - if request.Path != "" { - what += " at " + request.Path - } - if request.Ref != "" { - what += " on " + request.Ref - } - say("build", what) - - npmrc, err := packagesFrom(on) - if err == nil { - say("packages", saysRegistry(npmrc)) - } - registryHere, isRegistry := publisher.(builder.Registry) - var built builder.Result - if request.Check != nil { - // **A pull request's merge check, not a build** (novox/hq to-be 45 §9): nothing is built, - // published or registered; the verdict is the outcome. - result.Checked = request.Check - registry := "" - if r, ok := publisher.(builder.Registry); ok { - 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)} - } - 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), - Validator: v.Validator, Siblings: siblingsOut(v.Siblings), SiblingsUnknown: v.SiblingsUnknown, JudgeFrom: v.JudgeFrom} - // 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, - Commit: m.Head, Trunk: m.Trunk}) - } - // A branch tried before its pull request reads its outcome from this log, whole (novox/hq issue 686). - if request.Check.Try { - if line, err := json.Marshal(triedOf(v)); err == nil { - say(link.StepTried, string(line)) - } - } - } - } else if err == nil { - // The package-registry credential is a build input, so it is resolved before the clone: a - // build that could not have resolved its dependencies is refused in front of the reason, not - // after a clone that then fails at npm ci. - // Every container it starts is labelled with its id, so a kill finds what outlived the - // docker client (ADR 0219). - if request.Reuse != nil && isRegistry && !request.DryRun { - // **A walk's build takes the check's build of the same key** (novox/hq ADR 0350 §2). - reuse := builder.Reuse{Scratch: registryHere} - for _, p := range request.Reuse.Pulls { - 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) - 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) - } else { - built, err = builder.Build(building, builder.Labelled(builder.Command, request.ID), publisher, - request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc, - forgeFrom(), say, request.Seats) - } - } - // What it copied into the store, whatever became of the build (novox/hq ADR 0257). - result.Mirrored = built.Mirrored - // Only a build the kill ended: the kill came before its work did. One that finished — built, or - // failed on its own — in the moment the kill arrived says what it did, and the kill is refused. - killed := false - if mine != nil { - killed = h.returned(mine) && err != nil - } - if superseded.Load() && err != nil { - // Ended by its own key moving on (ADR 0406 §4): said as the check's failure, as a kill is. - result.Failed = link.SupersededByKey - say("failed", link.SupersededByKey) - } else if killed { - // **Killed by hand is the outcome, whatever the build was doing** (novox/hq ADR 0219): the - // error it ended with is the kill's consequence, not a fault of the source. - result.Failed = link.KilledByHand - say("failed", link.KilledByHand) - } else if err != nil { - // A failure is a result. A build that fails and says nothing is indistinguishable from a - // builder that is not running, and those want completely different responses. - result.Failed = err.Error() - say("failed", err.Error()) - } else if request.Check != nil { - say("checked", result.Check.Verdict+": "+result.Check.Summary) - } else { - manifest, marshalErr := json.Marshal(built.Manifest) - if marshalErr != nil { - result.Failed = marshalErr.Error() - } else { - result.Commit = built.Commit - result.Manifest = manifest - for _, made := range built.Built { - result.Made = append(result.Made, link.MadeArtifact{ - Name: made.Name, Kind: made.Kind, Reference: made.Reference, - }) - } - result.Against = built.Against - result.SourceFingerprint = built.Source - result.SourceInputs = built.SourceInputs - // What the walk did instead of building, or why it built (novox/hq ADR 0350 §3, §4). - result.ReuseKey = built.ReuseKey - if built.Reused != nil { - f := built.Reused.From - result.Reused = built.Reused.Words() - result.ReusedFrom = &link.ReusedFrom{Pull: link.PullRef{Owner: f.Pull.Owner, Repo: f.Pull.Repo, - Number: f.Pull.Number}, Head: f.Head, Trunk: f.Trunk, Check: f.Check, Key: f.Key} - } else if request.Reuse != nil { - result.BuiltWhy = built.Why - if result.BuiltWhy == "" { - result.BuiltWhy = "it has no artifact to build" - } - } - result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches - for _, r := range built.Read { - result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref}) - } - for _, s := range built.Sources { - result.Sources = append(result.Sources, link.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths}) - } - // The list of predefined keys the manifest was judged against, for the controller to publish - // (novox/hq ADR 0348 §7). - if p := built.Predefined; p != nil { - result.Predefined = &link.PredefinedList{Keys: p.Keys, Generation: p.Generation} - } - say("built", built.Manifest.Module+" from "+short(built.Commit)) - } - } - - // A killed build is announced on a context of its own: the build's was the one cancelled, and the - // outcome must go out and the ask be settled — acknowledged, never redelivered to another machine - // to be built again. A machine being stopped is the other case and keeps its meaning: the - // parent's context is gone, nothing is announced or settled, and the ask is redelivered. - announcing := ctx - if killed { - fresh, stop := context.WithTimeout(context.Background(), 30*time.Second) - defer stop() - announcing = fresh - } - announceErr := work.Announce(announcing, result) - if mine != nil { - h.said(mine, result.Failed, announceErr == nil) - } - if err := announceErr; err != nil { - // Said, not fatal: the build happened. A build reported as failed because announcing it - // failed is a lie about work that was done — and the request stays unsettled below only if - // nothing was said at all, so another machine can try. - fmt.Fprintf(os.Stderr, "cannot say what came of a build: %v\n", err) - return - } - - // Settled only once the outcome is away, so a machine that dies before answering leaves the work - // for another rather than losing it. - if err := work.Done(); err != nil { - fmt.Fprintf(os.Stderr, "the outcome is away and the request could not be settled: %v\n", err) - } -} - -// checkSpecOf is a check request as the builder runs it. -func checkSpecOf(request link.BuildRequest) builder.CheckSpec { - c := request.Check - spec := builder.CheckSpec{ID: request.ID, Repository: request.Repository, Ref: request.Ref, - 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, SiblingsUnknown: c.SiblingsUnknown} - for _, h := range c.HeldConfig { - spec.Held = append(spec.Held, builder.HeldValue{Pull: h.Pull, Head: h.Head, Node: h.Node, Module: h.Module, Key: h.Key, - Value: h.Value, Approved: h.Approved, By: h.By, At: h.At}) - } - for _, s := range c.Siblings { - spec.Siblings = append(spec.Siblings, builder.Sibling{Repository: s.Repository, Number: s.Number, Head: s.Head, - Base: s.Base, Dir: s.Dir}) - } - for dir, b := range c.Beside { - 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, - Ref: b.Ref, Merged: b.Merged, Tier: b.Tier, Skip: b.Skip}) - } - for _, m := range c.Members { - spec.Group = append(spec.Group, builder.GroupHead{Owner: m.Owner, Repo: m.Repo, Repository: m.Repository, - Ref: m.Ref, Paths: m.Paths, Base: m.Base}) - } - return spec -} - -// siblingsOut is the siblings a check read as the outcome carries them (novox/hq ADR 0382 §4). -func siblingsOut(read []builder.JudgedSibling) []link.CheckSibling { - var out []link.CheckSibling - for _, s := range read { - out = append(out, link.CheckSibling{Repository: s.Repository, Number: s.Number, Head: s.Head, Base: s.Base, - BaseCommit: s.BaseCommit}) - } - return out -} - -// 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 { - t := link.Tried{Verdict: v.Verdict, Summary: v.Summary, Took: v.Took.Round(time.Second).String(), - Head: v.Head, Trunk: v.Trunk} - if v.Repo != nil && (v.Gate == nil || v.Gate.Verdict == "pass") { - t.Verdict, t.Summary = v.Repo.Verdict, v.Repo.Summary - } - if v.Repo != nil && v.Repo.Failed != "" { - t.Failed = strings.Split(v.Repo.Failed, "\n") - } - if t.Verdict == "" { - t.Verdict = "error" - } - 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} - for _, m := range b.Made { - c.Made = append(c.Made, link.MadeArtifact{Name: m.Name, Kind: m.Kind, Reference: m.Reference}) - } - out = append(out, c) - } - return out -} - -// layerOf is one layer of a check as the outcome carries it. -func layerOf(l *builder.Layer) *link.CheckLayer { - if l == nil { - return nil - } - return &link.CheckLayer{Verdict: l.Verdict, Summary: l.Summary, Modules: l.Modules} -} - -// packagesFrom is where a build resolves the mesh's own published packages — the SDK above all -// (novox/hq ADR 0076, issue 053) — on this machine. -// -// **The binding wins, then the genesis document** (novox/hq ADR 0336 §3, §4). The build agent's -// npm-package-registry requirement is optional: on genesis's node, before the gitea module is assigned, -// nothing provides it, and the installer sealed the forge it raised to this agent as one document -// instead. Once the requirement binds, the binding names the endpoint the way the artifact store's -// does and a sealed token file the credential, and the genesis document is left unread. A binding -// file named and absent is a requirement not bound here, never a fault: the environment names the -// path whether or not the mesh wrote it. -// -// The environment variables remain for a builder run by a person. With none of the three the result -// is disabled and says this machine, so a build that needs the registry is refused naming it (§6), -// and a build that needs none builds anyway. -func packagesFrom(machine string) (builder.Npmrc, error) { - scope := strings.TrimSpace(os.Getenv("MESH_NPM_SCOPE")) - if scope == "" { - scope = "@novox" - } - none := builder.Npmrc{Machine: machine} - - if path := strings.TrimSpace(os.Getenv("MESH_PACKAGE_BINDING")); path != "" { - raw, err := os.ReadFile(path) - switch { - case errors.Is(err, os.ErrNotExist): - // Not bound on this machine: the genesis document, or nothing. - case err != nil: - return none, fmt.Errorf("cannot read what the mesh said about the package registry: %w", err) - default: - return fromBinding(path, raw, scope, machine) - } - } - if path := strings.TrimSpace(os.Getenv("MESH_GENESIS_PACKAGE_REGISTRY")); path != "" { - raw, err := os.ReadFile(path) - switch { - case errors.Is(err, os.ErrNotExist): - // Not this mesh's genesis node, or the installer has not sealed it yet. - case err != nil: - return none, fmt.Errorf("cannot read the genesis document for the package registry: %w", err) - default: - return fromGenesis(path, raw, scope, machine) - } - } - - // A builder run by a person. - registry := strings.TrimSpace(os.Getenv("MESH_NPM_REGISTRY")) - secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN")) - username := strings.TrimSpace(os.Getenv("MESH_NPM_USER")) - if registry == "" && secret == "" { - return none, nil - } - n := builder.Npmrc{Scope: scope, Registry: registry, From: builder.FromEnvironment, Machine: machine} - if username != "" { - n.Username, n.Password = username, secret - } else { - n.Token = secret - } - return n, nil -} - -// fromBinding composes the registry from the npm-package-registry binding the mesh wrote and the -// credential it sealed beside it. The mesh authenticates the ordinary way — a generated password the -// provider only applies (novox/hq ADR 0048) — so with a username the secret is a password (basic -// auth); without one it is a bearer token a provider minted. -func fromBinding(path string, raw []byte, scope, machine string) (builder.Npmrc, error) { - none := builder.Npmrc{Machine: machine} - var told struct { - From string `json:"from"` - At string `json:"at"` - As string `json:"as"` - Serves map[string]any `json:"serves"` - } - if err := json.Unmarshal(raw, &told); err != nil { - return none, fmt.Errorf("%s is not a binding: %w", path, err) - } - if told.At == "" { - return none, fmt.Errorf( - "%s says the package registry is on %q and gives no address for it", path, told.From) - } - // Composed from what the provider serves, so nothing here knows gitea's URL shape from - // another registry's: it states its port, the path its registry answers on, and the scheme. - scheme := "https" - if s, ok := told.Serves["scheme"]; ok { - scheme = fmt.Sprintf("%v", s) - } - port, ok := told.Serves["port"] - if !ok { - return none, fmt.Errorf("%s says nothing about which port the package registry answers on", path) - } - npmPath, ok := told.Serves["npm-path"] - if !ok { - return none, fmt.Errorf("%s says nothing about the path the package registry answers on", path) - } - secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN")) - if file := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); file != "" { - raw, err := os.ReadFile(file) - if err != nil { - return none, fmt.Errorf("cannot read this builder's package-registry credential: %w", err) - } - secret = strings.TrimSpace(string(raw)) - } - n := builder.Npmrc{Scope: scope, Registry: fmt.Sprintf("%s://%s:%v%v", scheme, told.At, port, npmPath), - From: builder.FromBinding, Machine: machine} - if told.As != "" { - n.Username, n.Password = told.As, secret - } else { - n.Token = secret - } - return n, nil -} - -// GenesisRegistry is the genesis document (novox/hq ADR 0336 §3): what the installer sealed to the -// build agent on its node at genesis's packages step, as the `genesis-package-registry` own-secret — -// the forge it raised, the port this node gave the package registry, its npm path, and the account it -// made with that account's password. mesh-host writes it; the field names are the two repositories' -// agreement. -type GenesisRegistry struct { - Scheme string `json:"scheme"` - At string `json:"at"` - Port int `json:"port"` - NpmPath string `json:"npm-path"` - As string `json:"as"` - Password string `json:"password"` -} - -// fromGenesis composes the registry from the genesis document. -func fromGenesis(path string, raw []byte, scope, machine string) (builder.Npmrc, error) { - none := builder.Npmrc{Machine: machine} - var g GenesisRegistry - if err := json.Unmarshal(raw, &g); err != nil { - return none, fmt.Errorf("%s is not the genesis document for the package registry: %w", path, err) - } - var missing []string - for name, value := range map[string]string{"at": g.At, "npm-path": g.NpmPath, "as": g.As, "password": g.Password} { - if strings.TrimSpace(value) == "" { - missing = append(missing, name) - } - } - if g.Port <= 0 { - missing = append(missing, "port") - } - if len(missing) > 0 { - sort.Strings(missing) - return none, fmt.Errorf("%s, the genesis document for the package registry, says nothing of %s", - path, strings.Join(missing, ", ")) - } - scheme := g.Scheme - if scheme == "" { - scheme = "http" - } - return builder.Npmrc{Scope: scope, Registry: fmt.Sprintf("%s://%s:%d%s", scheme, g.At, g.Port, g.NpmPath), - Username: g.As, Password: g.Password, From: builder.FromGenesis, Machine: machine}, nil -} - -// saysRegistry is the line each build's log carries about the package registry (novox/hq ADR 0336 -// §4): which of the binding and the genesis document it resolved from, or that it has neither here. -func saysRegistry(n builder.Npmrc) string { - if n.Enabled() { - return "the package registry is resolved from " + n.From - } - machine := n.Machine - if machine == "" { - machine = "this machine" - } - return fmt.Sprintf("no package registry: %s is unbound on %s and no genesis document is here; "+ - "a build needing it is refused", builder.RegistryRequirement, machine) -} - -// forgeFrom is the git credential this builder may offer a clone, composed from the same binding -// and sealed secret its package-registry half already reads: the forge that answers npm is the -// forge that hosts the repositories, and its provisioner applies one password to one user for -// both. Anything missing means no credential, and every clone stays anonymous — which is all a -// mesh of public repositories ever needs. -// -// The URL names the binding's own address — the machine the mesh says the forge is on — so a -// private repository is registered and built by that address, and a clone of anything else is -// never shown this credential (git's credential store matches the whole origin). -func forgeFrom() builder.GitCredential { - path := strings.TrimSpace(os.Getenv("MESH_PACKAGE_BINDING")) - if path == "" { - return builder.GitCredential{} - } - raw, err := os.ReadFile(path) - if err != nil { - return builder.GitCredential{} - } - var told struct { - At string `json:"at"` - As string `json:"as"` - Serves map[string]any `json:"serves"` - } - if err := json.Unmarshal(raw, &told); err != nil || told.At == "" || told.As == "" { - return builder.GitCredential{} - } - secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN")) - if file := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); file != "" { - if raw, err := os.ReadFile(file); err == nil { - secret = strings.TrimSpace(string(raw)) - } - } - if secret == "" { - return builder.GitCredential{} - } - scheme := "https" - if s, ok := told.Serves["scheme"]; ok { - scheme = fmt.Sprintf("%v", s) - } - host := told.At - if port, ok := told.Serves["port"]; ok { - host = fmt.Sprintf("%s:%v", told.At, port) - } - made := url.URL{Scheme: scheme, User: url.UserPassword(told.As, secret), Host: host} - return builder.GitCredential{URL: made.String()} -} - -func short(commit string) string { - if len(commit) > 8 { - return commit[:8] - } - return commit -} - -// whereToPublish is the artifact store this builder uses. -// -// **Preferably from the mesh.** A builder that is a module requires an artifact store, and the -// mesh writes it a file saying which machine answers that and on what port — the same binding any -// consumer of any provision gets. Reading it means the address is not a setting somebody keeps in -// step by hand, and moving the store is an ordinary reassignment rather than an edit on every -// build machine. -// -// The environment variable remains for a builder run by a person, which is how this started and -// how it is still run while being developed. -func whereToPublish() (string, error) { - binding := strings.TrimSpace(os.Getenv("MESH_BINDING")) - if binding == "" { - registry := strings.TrimSpace(os.Getenv("MESH_REGISTRY")) - if registry == "" { - return "", fmt.Errorf("neither MESH_BINDING nor MESH_REGISTRY: a built artifact " + - "nobody can fetch is not built") - } - return registry, nil - } - - raw, err := os.ReadFile(binding) - if err != nil { - return "", fmt.Errorf("cannot read what the mesh said about the artifact store: %w", err) - } - var told struct { - From string `json:"from"` - At string `json:"at"` - Serves map[string]any `json:"serves"` - } - if err := json.Unmarshal(raw, &told); err != nil { - return "", fmt.Errorf("%s is not a binding: %w", binding, err) - } - if told.At == "" { - // The provider is not on the private network, so there is no name to reach it by. Said - // rather than falling back to the machine's own name, which would publish to a store on - // the wrong machine and be found out much later. - return "", fmt.Errorf( - "%s says the artifact store is on %q and gives no address for it", binding, told.From) - } - port, ok := told.Serves["port"] - if !ok { - return "", fmt.Errorf("%s says nothing about which port the artifact store answers on", - binding) - } - return fmt.Sprintf("%s:%v", told.At, port), nil -} - -// brokerFrom is where this builder connects, and with what. -// -// **Preferably from a file the mesh sealed to this machine.** A builder that is a module is given -// its credential the way every other module is given one: generated or accepted centrally, sealed -// to the machine, written by the host. Putting it in an environment variable instead would mean -// the one copy that matters passing through a terminal and a process listing. -// -// The variable remains for a builder run by a person. -func brokerFrom() (Credential, error) { - if path := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")); path != "" { - raw, err := os.ReadFile(path) - if err != nil { - return Credential{}, fmt.Errorf("cannot read this builder's credential: %w", err) - } - said := strings.TrimSpace(string(raw)) - if said == "" { - // An empty credential file is a machine that will connect as nobody and be refused, - // with the reason three layers away. - return Credential{}, fmt.Errorf("%s is empty, so this builder has no credential", path) - } - var held Credential - if err := json.Unmarshal([]byte(said), &held); err == nil && held.URL != "" { - return held, nil - } - // A file holding only a URL, which is what a person writing one by hand produces. The - // broker is then verified against whatever this machine already trusts. - return Credential{URL: said}, nil - } - return Credential{}, fmt.Errorf( - "no MESH_BROKER_FILE: a build machine with no credential for the bus has nothing to build") -} - -// Credential is what a build machine is given so it can reach the broker. -// -// Two things, because reaching a broker over TLS needs both: who to connect as, and what to check -// the certificate against. A mesh's broker presents a certificate of the mesh's own, which is in -// no public trust store, so a URL alone can only connect to a broker somebody else vouches for. -// -// **The same shape a node gets, for the same reason** (novox/hq ADR 0004): the fingerprint travels -// out of band — here, sealed with the credential — and the endpoint is verified once at connect. -type Credential struct { - URL string `json:"url"` - Fingerprint string `json:"fingerprint,omitempty"` - // User and Password ride beside the address on the bus being built (design 25): a credential - // embedded in a URL leaks into every log line that prints a connection, so the mesh seals them - // as two fields and this machine joins them once, here, to dial. - User string `json:"user,omitempty"` - Password string `json:"password,omitempty"` - // Claims are the seats the module this credential was issued for claims, as the mesh writes - // them beside the credential (novox/hq ADR 0159). The first is the build role this machine - // serves; a credential naming none is from before claims travelled in it. - Claims []struct { - Seat string `json:"seat"` - } `json:"claims,omitempty"` -} - -// seatsClaimed is the seats the credential names, in order. -func (c Credential) seatsClaimed() []string { - out := make([]string, 0, len(c.Claims)) - for _, claim := range c.Claims { - out = append(out, claim.Seat) - } - return out -} - -// onTheNewBus is whether a credential is for the bus being built: its address says so, and the -// mesh only ever seals such a credential with the user and password beside it. -func (c Credential) onTheNewBus() bool { return strings.HasPrefix(strings.TrimSpace(c.URL), "nats://") } - -// natsURL is the address with this machine's credential in it, for the one dial that needs it. -func (c Credential) natsURL() string { - rest := strings.TrimPrefix(strings.TrimSpace(c.URL), "nats://") - if c.User == "" { - return "nats://" + rest - } - return "nats://" + c.User + ":" + c.Password + "@" + rest -} diff --git a/cmd/mesh-builder/once.go b/cmd/mesh-builder/once.go deleted file mode 100644 index 11df0bae..00000000 --- a/cmd/mesh-builder/once.go +++ /dev/null @@ -1,188 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "os" - "strings" - - "github.com/novox/mesh-controller/internal/builder" -) - -// buildOnce is the builder doing one build and stopping, with no broker and no mesh. -// -// **This is how a mesh is raised** (novox/hq ADR 0073). The installer carries this program and runs -// it once, before anything else exists, to produce the control plane from the same repository and -// path that every later rebuild of it will use. What raises the mesh is therefore the same thing -// that maintains it — not a second mechanism that has to be kept in step with the first and is -// exercised once per new mesh, which is how often enough to rot. -// -// It takes no work from a queue and answers nobody: there is no broker yet, and the only thing -// waiting for the answer is the installer that started it. So the result goes to standard output as -// JSON, which is what the installer reads. -// -// With no --registry it publishes nowhere and the image stays in this machine's container runtime, -// named by the digest of its own configuration (see builder.Local). That is the genesis case. With -// one, it publishes as it always does — the same command is how an operator builds a module by hand -// on a mesh that already exists. -func buildOnce(ctx context.Context, args []string) error { - set := flag.NewFlagSet("build", flag.ContinueOnError) - path := set.String("path", "", "the module's directory inside the repository (default: its root)") - ref := set.String("ref", "", "the commit to build; a branch is a moving target somebody else controls") - registry := set.String("registry", "", - "host:port to publish to. Without it the artifacts stay in this machine's container runtime, which is the genesis case") - workspace := set.String("workspace", "", "where to clone and build (default: a temporary directory)") - on := multiple(set, "on", - "a base this build stands on, as /=. Repeatable") - positionals, err := parseAround(set, args) - if err != nil { - return err - } - if len(positionals) != 1 { - return errors.New("mesh-builder build [--path P] [--ref COMMIT] [--registry HOST:PORT]") - } - repository := positionals[0] - - where := *workspace - if where == "" { - where = os.TempDir() + "/mesh-builder-once" - } - - // What this build may stand on, said on the command line because there is no mesh to ask. - // - // **This is the genesis case and it is meant to be awkward.** On a running mesh the control - // plane answers this, because only it knows what this mesh holds. Here nothing has been built - // yet, so whoever runs this says what to use — and for the control plane, which is the one - // module raised before anything else exists, the answer is ordinarily nothing at all. - bases := map[string]string{} - for _, pair := range *on { - key, reference, found := strings.Cut(pair, "=") - if !found || key == "" || reference == "" { - return fmt.Errorf( - "--on takes /=, and %q is not that", pair) - } - bases[key] = reference - } - - // Local unless told otherwise, because the moment this exists for has nowhere to publish. A - // default pointing at a registry would mean genesis failing at a push to something that is not - // there yet, one step away from the thing that could explain it. - var publisher builder.Publisher = builder.Local{Run: builder.Command} - if *registry != "" { - publisher = builder.Registry{Address: *registry, Run: builder.Command} - } - - fmt.Fprintf(os.Stderr, "building %s", repository) - if *path != "" { - fmt.Fprintf(os.Stderr, " at %s", *path) - } - if *ref != "" { - fmt.Fprintf(os.Stderr, " at %s", *ref) - } - fmt.Fprintln(os.Stderr) - - machine := os.Getenv("MESH_NODE") - if machine == "" { - machine, _ = os.Hostname() - } - npmrc, err := packagesFrom(machine) - if err != nil { - return err - } - fmt.Fprintf(os.Stderr, " [packages] %s\n", saysRegistry(npmrc)) - built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases, npmrc, - forgeFrom(), - func(step, message string) { fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) }) - if buildErr != nil { - return buildErr - } - - // To standard output, and everything else to standard error, so the caller can read this - // without having to separate it from progress. - out := onceResult{ - Module: built.Manifest.Module, - Commit: built.Commit, - Repository: repository, - Path: *path, - Ref: *ref, - Manifest: built.Manifest, - Against: built.Against, - Source: built.Source, - } - for _, r := range built.Read { - out.Read = append(out.Read, readRepository{Repository: r.Repository, Ref: r.Ref}) - } - for _, made := range built.Built { - out.Made = append(out.Made, madeArtifact{Name: made.Name, Kind: made.Kind, Reference: made.Reference}) - } - body, err := json.MarshalIndent(out, "", " ") - if err != nil { - return err - } - fmt.Println(string(body)) - fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit)) - return nil -} - -// onceResult is what one build reports to whoever started it. -// -// The same fields the mesh records for a build, so a reader comparing a genesis build against an -// ordinary one is comparing the same thing said the same way. -type onceResult struct { - Module string `json:"module"` - Commit string `json:"commit"` - Repository string `json:"repository"` - Path string `json:"path,omitempty"` - Ref string `json:"ref,omitempty"` - Manifest any `json:"manifest"` - Made []madeArtifact `json:"made"` - Against []string `json:"against,omitempty"` - Read []readRepository `json:"read,omitempty"` - Source string `json:"source-fingerprint,omitempty"` -} - -// readRepository is a repository this build read source from besides the module's own. -type readRepository struct { - Repository string `json:"repository"` - Ref string `json:"ref,omitempty"` -} - -type madeArtifact struct { - Name string `json:"name"` - Kind string `json:"kind"` - Reference string `json:"reference"` -} - -// parseAround lets flags appear on either side of the repository, because a person writing this by -// hand will put them wherever reads best and the standard parser stops at the first thing that is -// not a flag. The same helper the control plane's commands use, for the same reason. -func parseAround(set *flag.FlagSet, args []string) ([]string, error) { - var positionals []string - rest := args - for { - if err := set.Parse(rest); err != nil { - return nil, err - } - rest = set.Args() - if len(rest) == 0 { - return positionals, nil - } - positionals = append(positionals, rest[0]) - rest = rest[1:] - } -} - -// multiple is a flag that may be given more than once. -type repeated []string - -func (r *repeated) String() string { return strings.Join(*r, ", ") } -func (r *repeated) Set(v string) error { *r = append(*r, v); return nil } - -func multiple(set *flag.FlagSet, name, usage string) *[]string { - var values repeated - set.Var(&values, name, usage) - return (*[]string)(&values) -} diff --git a/cmd/mesh-builder/packages_test.go b/cmd/mesh-builder/packages_test.go deleted file mode 100644 index 2fb4a719..00000000 --- a/cmd/mesh-builder/packages_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/builder" -) - -// The build agent's package registry (novox/hq ADR 0336, issue 121): its npm-package-registry -// requirement is optional, so the binding may be absent, and on genesis's node the installer seals -// the forge it raised as one genesis document instead. - -// registryFiles names the three paths the build agent's environment always names, writing only those -// given. -func registryFiles(t *testing.T, binding, token, genesis string) { - t.Helper() - dir := t.TempDir() - for env, content := range map[string]string{ - "MESH_PACKAGE_BINDING": binding, - "MESH_NPM_TOKEN_FILE": token, - "MESH_GENESIS_PACKAGE_REGISTRY": genesis, - } { - path := filepath.Join(dir, env) - t.Setenv(env, path) - if content != "" { - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - } - } - for _, env := range []string{"MESH_NPM_REGISTRY", "MESH_NPM_TOKEN", "MESH_NPM_USER", "MESH_NPM_SCOPE"} { - t.Setenv(env, "") - } -} - -const ( - aBinding = `{"from":"gitea","at":"anchor.mesh","as":"anchor-agent", - "serves":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/"}}` - aGenesisDocument = `{"scheme":"http","at":"127.0.0.1","port":3100,"npm-path":"/api/packages/novox/npm/", - "as":"mesh-builder","password":"genesis-secret"}` -) - -func TestUntilTheRequirementBindsTheGenesisDocumentIsRead(t *testing.T) { - registryFiles(t, "", "", aGenesisDocument) - n, err := packagesFrom("anchor") - if err != nil { - t.Fatalf("an absent binding beside a genesis document was refused: %v", err) - } - if n.From != builder.FromGenesis || n.Registry != "http://127.0.0.1:3100/api/packages/novox/npm/" || - n.Username != "mesh-builder" || n.Password != "genesis-secret" || n.Scope != "@novox" { - t.Fatalf("the genesis document was not what the registry came from: %+v", n) - } - file, err := n.File() - if err != nil || !strings.Contains(file, "//127.0.0.1:3100/api/packages/novox/npm/:username=mesh-builder") { - t.Fatalf("the .npmrc is not written from the genesis document: %v\n%s", err, file) - } - if got := saysRegistry(n); !strings.Contains(got, "genesis document") { - t.Errorf("the log does not say the genesis document: %s", got) - } -} - -func TestARealBindingWinsOverTheGenesisDocument(t *testing.T) { - registryFiles(t, aBinding, "bound-secret\n", aGenesisDocument) - n, err := packagesFrom("anchor") - if err != nil { - t.Fatal(err) - } - if n.From != builder.FromBinding || n.Registry != "http://anchor.mesh:3000/api/packages/novox/npm/" || - n.Username != "anchor-agent" || n.Password != "bound-secret" { - t.Fatalf("the binding did not win over the genesis document: %+v", n) - } - if got := saysRegistry(n); !strings.Contains(got, "binding") { - t.Errorf("the log does not say the binding: %s", got) - } -} - -func TestWithNeitherTheAgentHasNoRegistryAndSaysWhere(t *testing.T) { - registryFiles(t, "", "", "") - n, err := packagesFrom("workstation") - if err != nil { - t.Fatalf("an unbound optional requirement was a fault: %v", err) - } - if n.Enabled() || n.Machine != "workstation" { - t.Fatalf("a registry came from nowhere: %+v", n) - } - got := saysRegistry(n) - for _, want := range []string{"npm-package-registry", "unbound", "workstation"} { - if !strings.Contains(got, want) { - t.Errorf("the log does not say %q: %s", want, got) - } - } -} - -func TestAGenesisDocumentMissingAFieldIsRefusedByName(t *testing.T) { - registryFiles(t, "", "", `{"at":"127.0.0.1","port":3000,"npm-path":"/api/packages/novox/npm/","as":"mesh-builder"}`) - if _, err := packagesFrom("anchor"); err == nil || !strings.Contains(err.Error(), "password") { - t.Fatalf("a genesis document with no password was taken: %v", err) - } -} diff --git a/cmd/mesh-builder/ready.go b/cmd/mesh-builder/ready.go deleted file mode 100644 index 09b770a6..00000000 --- a/cmd/mesh-builder/ready.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "path/filepath" -) - -// **The build agent says it is ready** (novox/hq issue 544). It listens on nothing, so no http or tcp -// check can see it work; it writes a mark once it is bound to its worker and pulls build work, and its -// container's health check runs `mesh-builder ready`, which passes only while the mark is there. Without -// it the node-engine could judge the agent only alive, and every walk that moved it waited out the whole -// grace on each machine. -// -// The mark lives in the container's own temporary directory, never in the workspace the machine keeps: -// a new container starts without it, and a restarted one removes it before it binds again. -func readyMark() string { return filepath.Join(os.TempDir(), "mesh-builder.ready") } - -// notReady removes the mark: a holder starting is not taking work until it says so again. -func notReady() error { - if err := os.Remove(readyMark()); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("cannot remove the mark that this agent takes build work (%s): %w", readyMark(), err) - } - return nil -} - -// taking writes the mark. A mark that cannot be written is said, and the agent goes on building: its -// check then says it is not ready, which is the truth the gate should hear. -func taking(seat string) { - if err := os.WriteFile(readyMark(), []byte(seat+"\n"), 0o644); err != nil { - fmt.Fprintf(os.Stderr, "cannot mark that this agent takes build work (%s): %v\n", readyMark(), err) - return - } - fmt.Fprintf(os.Stderr, "taking build work from %s\n", seat) -} - -// ready is the health check: nil when this container's agent is bound and pulling build work. -func ready() error { - if _, err := os.Stat(readyMark()); err != nil { - return fmt.Errorf("not taking build work yet: %s is not there", readyMark()) - } - return nil -} diff --git a/cmd/mesh-builder/ready_test.go b/cmd/mesh-builder/ready_test.go deleted file mode 100644 index 4e96679a..00000000 --- a/cmd/mesh-builder/ready_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "os" - "testing" -) - -// The health check passes only between the agent saying it takes work and its next start (novox/hq -// issue 544). -func TestTheAgentIsReadyOnlyOnceItTakesWork(t *testing.T) { - t.Setenv("TMPDIR", t.TempDir()) - if err := ready(); err == nil { - t.Fatal("an agent that never took work is ready") - } - taking("mesh-build-machine") - if err := ready(); err != nil { - t.Fatalf("an agent taking work is not ready: %v", err) - } - // Started again, it is not ready until it binds again. - if err := notReady(); err != nil { - t.Fatal(err) - } - if err := ready(); err == nil { - t.Fatal("an agent starting again is ready before it takes work") - } - if err := notReady(); err != nil { - t.Fatalf("removing a mark that is not there failed: %v", err) - } - if _, err := os.Stat(readyMark()); err == nil { - t.Fatal("the mark is still there") - } -} diff --git a/cmd/mesh-builder/seat_test.go b/cmd/mesh-builder/seat_test.go deleted file mode 100644 index 85186465..00000000 --- a/cmd/mesh-builder/seat_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "encoding/json" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -// The seat a build machine serves comes from its credential (novox/hq ADR 0190 handover). -func TestTheCredentialSaysWhichBuildRoleThisMachineServes(t *testing.T) { - var held Credential - if err := json.Unmarshal([]byte(`{"url":"nats://bus:4222","user":"anchor.builder","password":"x", - "claims":[{"seat":"mesh-build-machine","scope":"mesh","serves":[]}]}`), &held); err != nil { - t.Fatal(err) - } - if got := link.BuildSeatClaimed(held.seatsClaimed()); got != "mesh-build-machine" { - t.Errorf("the old builder's credential serves %q", got) - } - var bare Credential - if err := json.Unmarshal([]byte(`{"url":"nats://bus:4222","user":"anchor.build-agent","password":"x"}`), &bare); err != nil { - t.Fatal(err) - } - if got := link.BuildSeatClaimed(bare.seatsClaimed()); got != link.TheBuildMachine { - t.Errorf("a credential without claims serves %q, want %s", got, link.TheBuildMachine) - } -} diff --git a/cmd/mesh-builder/stdout_test.go b/cmd/mesh-builder/stdout_test.go deleted file mode 100644 index f33e9401..00000000 --- a/cmd/mesh-builder/stdout_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "os" - "strings" - "testing" -) - -// **The genesis path writes its result to stdout and nothing else there.** The installer captures -// stdout alone and parses it as JSON; one stray log line makes that fail with "invalid character" -// — exactly what a builder fmt.Printf caused, breaking a build that had worked. This keeps -// diagnostics on stderr so a future print cannot repeat it silently. -func TestBuilderDiagnosticsStayOffStdout(t *testing.T) { - allowed := map[string]bool{ - "string(body)": true, // once.go: the result JSON, which IS stdout - "version)": true, // --version - `"stopping")`: true, // the loop.s shutdown line - "usage)": true, // --help text, for a human - "said)": true, // busy: the answer its node-engine reads (novox/hq ADR 0335) - } - for _, file := range []string{"once.go", "main.go"} { - src, err := os.ReadFile(file) - if err != nil { - t.Fatal(err) - } - for i, raw := range strings.Split(string(src), "\n") { - line := strings.TrimSpace(raw) - if !strings.HasPrefix(line, "fmt.Printf(") && - !strings.HasPrefix(line, "fmt.Println(") && - !strings.HasPrefix(line, "fmt.Print(") { - continue - } - ok := false - for token := range allowed { - if strings.Contains(line, token) { - ok = true - } - } - if !ok { - t.Errorf("%s:%d writes a diagnostic to stdout, which the installer parses as JSON:\n %s", - file, i+1, line) - } - } - } -} diff --git a/cmd/mesh-builder/tried_test.go b/cmd/mesh-builder/tried_test.go deleted file mode 100644 index 06aaf706..00000000 --- a/cmd/mesh-builder/tried_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -import ( - "testing" - "time" - - "github.com/novox/mesh-controller/internal/builder" -) - -// A try's outcome, as the build seat says it in the build's log (novox/hq issue 686): the repository's own -// check's verdict, how long it took, and each line naming what failed. -func TestATrysOutcomeNamesWhatFailed(t *testing.T) { - tried := triedOf(builder.CheckVerdict{Verdict: "pass", Summary: "the change touches no module", Took: 303 * time.Second, - Head: "c0ffee", Trunk: "ca3f43c", Gate: &builder.Layer{Verdict: "pass"}, - Repo: &builder.Layer{Verdict: "fail", Summary: "its merge-check.sh failed: TestX", - Failed: "--- FAIL: TestX (0.01s)\n x_test.go:12: got 1, want 2"}}) - if tried.Verdict != "fail" || tried.Took != "5m3s" || len(tried.Failed) != 2 || tried.Trunk != "ca3f43c" || - tried.Summary != "its merge-check.sh failed: TestX" { - t.Fatalf("a failed try: %+v", tried) - } - // A head that does not merge fails both layers: the check's own word. - conflict := triedOf(builder.CheckVerdict{Verdict: "fail", Summary: "does not merge", Gate: &builder.Layer{Verdict: "fail"}, - Repo: &builder.Layer{Verdict: "fail", Summary: "does not merge"}}) - if conflict.Verdict != "fail" || conflict.Summary != "does not merge" { - t.Fatalf("a try that does not merge: %+v", conflict) - } - // No verdict is an error, never a pass. - if none := triedOf(builder.CheckVerdict{}); none.Verdict != "error" { - t.Fatalf("a try with no verdict: %+v", none) - } -} diff --git a/cmd/mesh-builder/where_test.go b/cmd/mesh-builder/where_test.go deleted file mode 100644 index 5a55bdb7..00000000 --- a/cmd/mesh-builder/where_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package main - -import ( - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/hex" - "math/big" - "net" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/broker" -) - -// Where a builder publishes. -// -// Preferably from the mesh: a builder that is a module requires an artifact store, and the mesh -// writes it a binding saying which machine answers and on what port. Reading it means the address -// is not a setting somebody keeps in step by hand. - -func binding(t *testing.T, body string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "artifact-store.json") - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - return path -} - -func TestTheMeshSaysWhereToPublish(t *testing.T) { - t.Setenv("MESH_BINDING", binding(t, `{"binding":1,"provision":"artifact-store", - "from":"anchor","at":"anchor.internal","serves":{"port":5000,"scheme":"http"}}`)) - where, err := whereToPublish() - if err != nil { - t.Fatal(err) - } - if where != "anchor.internal:5000" { - t.Fatalf("got %q", where) - } -} - -func TestABindingWithNoAddressIsRefused(t *testing.T) { - // The provider is not on the private network, so there is no name to reach it by. Falling - // back to anything would publish to a store on the wrong machine and be found out much later. - t.Setenv("MESH_BINDING", binding(t, `{"binding":1,"from":"anchor","serves":{"port":5000}}`)) - _, err := whereToPublish() - if err == nil { - t.Fatal("a binding with nowhere to reach was accepted") - } - if !strings.Contains(err.Error(), "anchor") { - t.Fatalf("the failure does not name the machine: %v", err) - } -} - -func TestABindingWithNoPortIsRefused(t *testing.T) { - t.Setenv("MESH_BINDING", binding(t, `{"binding":1,"from":"a","at":"a.internal","serves":{}}`)) - if _, err := whereToPublish(); err == nil { - t.Fatal("a binding saying nothing about a port was accepted") - } -} - -func TestTheVariableStillWorksForABuilderRunByAPerson(t *testing.T) { - // Which is how this started and how it is still run while being developed. - t.Setenv("MESH_BINDING", "") - t.Setenv("MESH_REGISTRY", "127.0.0.1:5000") - where, err := whereToPublish() - if err != nil { - t.Fatal(err) - } - if where != "127.0.0.1:5000" { - t.Fatalf("got %q", where) - } -} - -func TestNeitherIsRefusedRatherThanGuessed(t *testing.T) { - t.Setenv("MESH_BINDING", "") - t.Setenv("MESH_REGISTRY", "") - if _, err := whereToPublish(); err == nil { - t.Fatal("a builder with nowhere to publish reported somewhere") - } -} - -func TestTheCredentialComesFromAFileTheMeshSealed(t *testing.T) { - // A builder that is a module is given its credential the way every module is: sealed to the - // machine and written by the host. An environment variable instead would put the one copy - // that matters through a terminal and a process listing. - path := filepath.Join(t.TempDir(), "broker") - if err := os.WriteFile(path, []byte("amqps://a-builder:secret@broker.internal:5671/\n"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("MESH_BROKER_FILE", path) - t.Setenv("MESH_BROKER_AMQP", "amqp://should-not-be-used@nowhere/") - - got, err := brokerFrom() - if err != nil { - t.Fatal(err) - } - if got.URL != "amqps://a-builder:secret@broker.internal:5671/" { - t.Fatalf("got %q", got.URL) - } -} - -// The credential the mesh seals carries what to check the broker's certificate against, because a -// mesh's broker presents a certificate of the mesh's own and no public trust store has it. A URL -// alone can only reach a broker somebody else vouches for. -func TestTheSealedCredentialCarriesWhatVerifiesTheBroker(t *testing.T) { - path := filepath.Join(t.TempDir(), "broker") - if err := os.WriteFile(path, []byte( - `{"url":"amqps://a-builder:secret@broker.internal:5671/","fingerprint":"abc123"}`), - 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("MESH_BROKER_FILE", path) - t.Setenv("MESH_BROKER_AMQP", "") - - got, err := brokerFrom() - if err != nil { - t.Fatal(err) - } - if got.URL != "amqps://a-builder:secret@broker.internal:5671/" { - t.Fatalf("the url was lost: %q", got.URL) - } - if got.Fingerprint != "abc123" { - t.Fatal("the builder was given nothing to check the broker against, so it can only " + - "connect to a broker some public authority vouches for") - } -} - -func TestAnEmptyCredentialFileIsRefused(t *testing.T) { - // Otherwise the builder connects as nobody and is refused, with the reason three layers away. - path := filepath.Join(t.TempDir(), "broker") - if err := os.WriteFile(path, []byte("\n"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("MESH_BROKER_FILE", path) - t.Setenv("MESH_BROKER_AMQP", "") - if _, err := brokerFrom(); err == nil { - t.Fatal("an empty credential was accepted") - } -} - -func TestABuilderWithNoCredentialAtAllSaysSo(t *testing.T) { - t.Setenv("MESH_BROKER_FILE", "") - t.Setenv("MESH_BROKER_AMQP", "") - if _, err := brokerFrom(); err == nil { - t.Fatal("a builder with no broker reported one") - } -} - -// The pin is compared in the spelling the mesh writes it. -// -// A bare digest against a written fingerprint never matches, and the failure is indistinguishable -// from being pointed at the wrong broker — which is the one thing this check exists to report -// truthfully. It cost a lab run. -func TestThePinIsComparedInTheSpellingTheMeshWritesIt(t *testing.T) { - certificate, key := aServerCertificate(t) - der := certificate.Certificate[0] - sum := sha256.Sum256(der) - written := "sha256:" + hex.EncodeToString(sum[:]) - - listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ - Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12, - }) - if err != nil { - t.Fatal(err) - } - defer listener.Close() - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - _ = conn.(*tls.Conn).Handshake() - conn.Close() - } - }() - _ = key - - // The pin the mesh wrote must be accepted. - if err := handshakeWith(listener.Addr().String(), written); err != nil { - t.Fatalf("the broker this builder was told about was refused: %v", err) - } - // And a different one refused, or the check reports nothing. - other := "sha256:" + strings.Repeat("ab", 32) - if err := handshakeWith(listener.Addr().String(), other); err == nil { - t.Fatal("a broker this builder was not told about was accepted") - } -} - -// handshakeWith runs the pin check the builder dials with against an address. -func handshakeWith(address, pin string) error { - conn, err := tls.Dial("tcp", address, broker.PinnedToFingerprint(pin)) - if err != nil { - return err - } - return conn.Close() -} - -func aServerCertificate(t *testing.T) (tls.Certificate, ed25519.PrivateKey) { - t.Helper() - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "a broker"}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(time.Hour), - IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, - } - der, err := x509.CreateCertificate(rand.Reader, template, template, public, private) - if err != nil { - t.Fatal(err) - } - return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: private}, private -} diff --git a/cmd/mesh-controller/addresses_integration_test.go b/cmd/mesh-controller/addresses_integration_test.go index d8c507b2..363f05e1 100644 --- a/cmd/mesh-controller/addresses_integration_test.go +++ b/cmd/mesh-controller/addresses_integration_test.go @@ -221,8 +221,4 @@ func TestOnANodeWithNoNetworkTheStoreIsReachedByLoopback(t *testing.T) { if image != "127.0.0.1:5100/postgres/runtime@"+aDigest { t.Fatalf("on the store's own node, off any network, the image is fetched as %v", image) } - held := heldBy(ctx) - if got := held["postgres/runtime"]; got != "127.0.0.1:5100/postgres/runtime@"+aDigest { - t.Fatalf("a builder beside the store is handed the base %q", got) - } } diff --git a/cmd/mesh-controller/addresses_test.go b/cmd/mesh-controller/addresses_test.go index ec44be96..09ed3438 100644 --- a/cmd/mesh-controller/addresses_test.go +++ b/cmd/mesh-controller/addresses_test.go @@ -1,12 +1,9 @@ package main import ( - "encoding/json" "strings" - "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/link" ) // An address is read from the node's settings where it is used, never recorded with a port @@ -14,62 +11,6 @@ import ( var aDigest = "sha256:" + strings.Repeat("e", 64) -// **A build is recorded by digest and path**, whatever address the builder pushed to — what it -// made and what it stood on both; an image the module runs from elsewhere is left where it says. -func TestABuildIsRecordedWithoutTheStoresAddress(t *testing.T) { - manifest, _ := json.Marshal(map[string]any{ - "module": "gitea", "version": "1", - "resources": []map[string]any{ - {"id": "server", "type": "container", "name": "mesh-gitea", - "image": "anchor.internal:5100/gitea/server@" + aDigest}, - {"id": "config", "type": "archive", "path": "/etc/gitea", "digest": aDigest, - "source": "http://anchor.internal:5100/v2/gitea/config/blobs/" + aDigest}, - {"id": "cache", "type": "container", "name": "mesh-gitea-cache", - "image": "valkey/valkey@" + aDigest}, - }, - }) - kept := buildFrom(link.BuildResult{ - ID: "b1", Repository: "https://forge.example/gitea.git", Commit: "abc", On: "laptop", - Manifest: manifest, - Made: []link.MadeArtifact{ - {Name: "server", Kind: "image", Reference: "anchor.internal:5100/gitea/server@" + aDigest}, - {Name: "config", Kind: "archive", Reference: "http://anchor.internal:5100/v2/gitea/config/blobs/" + aDigest}, - }, - Against: []string{"anchor.internal:5100/mesh-tools/runtime@" + aDigest}, - }) - if kept.Module != "gitea" { - t.Fatalf("the module was not read from the recorded manifest: %q", kept.Module) - } - if kept.Made[0].Reference != catalogue.ArtifactStoreScheme+"gitea/server@"+aDigest { - t.Errorf("the image is recorded as %q, address and all", kept.Made[0].Reference) - } - if kept.Made[1].Reference != catalogue.ArtifactStoreScheme+"gitea/config/blobs/"+aDigest { - t.Errorf("the archive is recorded as %q, address and all", kept.Made[1].Reference) - } - recorded, err := catalogue.ParseManifest(kept.Manifest) - if err != nil { - t.Fatal(err) - } - if got := recorded.Resources[0]["image"]; got != catalogue.ArtifactStoreScheme+"gitea/server@"+aDigest { - t.Errorf("the recorded manifest's image is %v", got) - } - if got := recorded.Resources[1]["source"]; got != catalogue.ArtifactStoreScheme+"gitea/config/blobs/"+aDigest { - t.Errorf("the recorded manifest's archive is %v", got) - } - if got := recorded.Resources[2]["image"]; got != "valkey/valkey@"+aDigest { - t.Errorf("an image the build did not make was rewritten: %v", got) - } - if strings.Contains(string(kept.Manifest), "anchor.internal:5100") { - t.Errorf("the recorded manifest still carries the store's address:\n%s", kept.Manifest) - } - // What the build stood on is an edge to another module's artifact, and it is recorded the way - // that artifact is: by path in the store, so the edge still names the same thing when the - // store answers at another address. - if kept.Against[0] != catalogue.ArtifactStoreScheme+"mesh-tools/runtime@"+aDigest { - t.Errorf("what the build stood on was recorded by address: %v", kept.Against) - } -} - // aStore is a module offering the artifact store on 5000, published the long way as the // distribution module does, so a node may be given another number for it. func aStore() catalogue.Manifest { diff --git a/cmd/mesh-controller/ask_bus_look_integration_test.go b/cmd/mesh-controller/ask_bus_look_integration_test.go deleted file mode 100644 index d4b1eee4..00000000 --- a/cmd/mesh-controller/ask_bus_look_integration_test.go +++ /dev/null @@ -1,131 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" -) - -// Issue 1334, the look: when the bus did not answer an ask of any module of a tier, no build is in flight and no event follows, -// so the look itself comes back (it fails, and the looker retries it), asks the modules again, and past the bound fails the plan. -func TestALookComesBackForATierTheBusRefusedWhole(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - inv := open.inventory - for _, name := range []string{"a", "b"} { - if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: name, Version: "1"}, - inventory.Source{Repository: "novox/" + name, Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { - t.Fatal(err) - } - } - p := inventory.Plan{ID: "plan-bus", Repository: "novox/a", Branch: "main", Commit: "c1", Created: time.Now().UTC(), - State: inventory.PlanBuilding, Tiers: [][]string{{"a", "b"}}, Modules: map[string]*inventory.PlanModule{"a": {}, "b": {}}} - if err := inv.SavePlan(ctx, &p); err != nil { - t.Fatal(err) - } - bus := false - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - was := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - if !bus { - return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain) - } - return "build-" + source.Repository, nil - } - t.Cleanup(func() { askABuild = was }) - look := func() bool { - t.Helper() - release, err := inv.HoldPlans(ctx, true) - if err != nil { - t.Fatal(err) - } - defer release() - return advanceHeld(ctx, open) - } - read := func() inventory.Plan { - t.Helper() - got, err := inv.PlanByID(ctx, "plan-bus") - if err != nil { - t.Fatal(err) - } - return got - } - - // Every module refused: the look says it failed, which has the looker come back; the plan is building and says why. - if !look() { - t.Fatal("a look that left the whole tier unasked for the bus did not fail, so nothing would come back to it") - } - if got := read(); got.State != inventory.PlanBuilding || !strings.Contains(got.Note, "bus did not answer") || - got.Modules["a"].State != "" || got.Modules["b"].State != "" { - t.Fatalf("the plan after a refused tier: %s %q %+v %+v", got.State, got.Note, got.Modules["a"], got.Modules["b"]) - } - // Again, still refused: the same fact is not saved again, and the look still comes back. - before := read().Revision - if !look() || read().Revision != before { - t.Fatalf("a second refused look failed to come back or saved the plan again (revision %d, was %d)", read().Revision, before) - } - - // The bus answers at the next look: both are asked, and nothing waits any more. - bus = true - if look() { - t.Fatal("a look that asked the whole tier failed") - } - if got := read(); got.Modules["a"].State != "asked" || got.Modules["b"].State != "asked" || got.Modules["a"].BusWaitFrom != nil { - t.Fatalf("the tier was not asked once the bus answered: %+v %+v", got.Modules["a"], got.Modules["b"]) - } -} - -func TestALookFailsTheTierTheBusRefusedPastTheBound(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - inv := open.inventory - if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "a", Version: "1"}, - inventory.Source{Repository: "novox/a", Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { - t.Fatal(err) - } - long := time.Now().UTC().Add(-busWaitBound - time.Minute) - p := inventory.Plan{ID: "plan-bus", Repository: "novox/a", Branch: "main", Commit: "c1", Created: time.Now().UTC(), - State: inventory.PlanBuilding, Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{"a": {BusWaitFrom: &long}}} - if err := inv.SavePlan(ctx, &p); err != nil { - t.Fatal(err) - } - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain) - } - t.Cleanup(func() { askABuild = was }) - release, err := inv.HoldPlans(ctx, true) - if err != nil { - t.Fatal(err) - } - advanceHeld(ctx, open) - release() - got, err := inv.PlanByID(ctx, "plan-bus") - if err != nil { - t.Fatal(err) - } - if got.State != inventory.PlanFailed || !strings.Contains(got.Note, "the bus did not answer an ask for 10m0s (since ") { - t.Fatalf("a bus silent past its bound did not fail the plan in words: %s %q", got.State, got.Note) - } - - // And retried by hand: the module is named as waiting, not as asked again. - got, _ = inv.PlanByID(ctx, "plan-bus") - if got.State == inventory.PlanFailed { - got.Modules["a"].State, got.Modules["a"].Why = "failed", "x" - if err := inv.SavePlan(ctx, &got); err != nil { - t.Fatal(err) - } - said, err := retryPlan(ctx, open, "plan-bus") - if err != nil || !strings.Contains(said, "not asked yet: the bus did not answer") { - t.Fatalf("a retry left for the bus is said as %q %v", said, err) - } - } -} diff --git a/cmd/mesh-controller/ask_bus_moment_test.go b/cmd/mesh-controller/ask_bus_moment_test.go deleted file mode 100644 index 70163618..00000000 --- a/cmd/mesh-controller/ask_bus_moment_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/inventory" -) - -// Issue 1334: a bus that does not answer for a moment (it reloads its user list, and JetStream's API has no responder for a few -// seconds) is not a failed build. The module is left unasked and the plan building, and the next look asks it again; only a -// module's own fault, or a bus silent for ten minutes, fails the plan. - -// asksOfEveryModule makes every module a test names the builder's own, the one module a walk still asks build-agent -// for (ADR 0453 §5): what a test of that ask's waits reads. -func asksOfEveryModule(t *testing.T) { - t.Helper() - was := buildersOwn - buildersOwn = func(string) bool { return true } - t.Cleanup(func() { buildersOwn = was }) -} - -func aTierOfThree() (*inventory.Plan, map[string]inventory.Entry) { - p := &inventory.Plan{ID: "plan-1", State: inventory.PlanBuilding, Tiers: [][]string{{"a", "b", "c"}}, - Modules: map[string]*inventory.PlanModule{}} - byName := map[string]inventory.Entry{} - for _, m := range []string{"a", "b", "c"} { - byName[m] = inventory.Entry{Source: inventory.Source{Repository: "novox/" + m, Seat: "git", Ref: "main"}} - } - return p, byName -} - -func TestABusThatDoesNotAnswerForAMomentDoesNotFailTheTier(t *testing.T) { - asksOfEveryModule(t) - p, byName := aTierOfThree() - bus := false - was := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - if source.Repository == "novox/b" && !bus { - return "", fmt.Errorf("%w: the bucket mesh-controller_builds is not on the bus: nats: no responders available for request", - inventory.ErrNoChain) - } - return "build-" + source.Repository, nil - } - t.Cleanup(func() { askABuild = was }) - - for _, m := range []string{"a", "b", "c"} { - askModule(t.Context(), p, m, byName) - } - if p.State != inventory.PlanBuilding { - t.Fatalf("a bus that did not answer for a moment failed the plan: %s, %q", p.State, p.Note) - } - if p.Modules["a"].State != "asked" || p.Modules["c"].State != "asked" { - t.Fatalf("the modules the bus did answer for were not asked: %+v %+v", p.Modules["a"], p.Modules["c"]) - } - if s := p.Modules["b"]; s.State != "" { - t.Fatalf("the module the bus did not answer for is %q, not left unasked", s.State) - } - if got := unaskedOf(*p); len(got) != 1 || got[0] != "b" { - t.Fatalf("the next look would ask %v, not b", got) - } - if !strings.Contains(p.Note, "bus did not answer") { - t.Fatalf("the plan says nothing of the wait: %q", p.Note) - } - - // The next look, the bus answering: b is asked and the plan goes on. - bus = true - for _, m := range unaskedOf(*p) { - askModule(t.Context(), p, m, byName) - } - if p.State != inventory.PlanBuilding || p.Modules["b"].State != "asked" || p.Modules["b"].Build == "" { - t.Fatalf("b was not asked once the bus answered: %s %+v", p.State, p.Modules["b"]) - } -} - -func TestABusSilentForTenMinutesFailsTheTierAsBefore(t *testing.T) { - asksOfEveryModule(t) - p, byName := aTierOfThree() - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain) - } - t.Cleanup(func() { askABuild = was }) - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanBuilding { - t.Fatalf("the first refusal failed the plan: %q", p.Note) - } - long := time.Now().Add(-busWaitBound - time.Minute) - p.Modules["a"].BusWaitFrom = &long - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanFailed || p.Modules["a"].State != "failed" { - t.Fatalf("a bus silent past its bound did not fail the plan: %s %+v", p.State, p.Modules["a"]) - } - // Said in plain words: the bus, the wait, and since when. - want := "a could not be asked for: the bus did not answer an ask for 10m0s (since " + long.UTC().Format(time.RFC3339) + - "): this kind of record lives on the bus" - if !strings.HasPrefix(p.Note, want) || p.Modules["a"].Why != strings.TrimPrefix(want, "a could not be asked for: ")+p.Note[len(want):] { - t.Fatalf("the plan says %q, not words starting %q", p.Note, want) - } - // The wait has ended: a Retry made while the bus still reloads waits again, and does not fail at its first refusal. - if p.Modules["a"].BusWaitFrom != nil { - t.Fatalf("a failed module still holds the start of its wait: %v", p.Modules["a"].BusWaitFrom) - } - p.State = inventory.PlanBuilding - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanBuilding || p.Modules["a"].State != "" || p.Modules["a"].BusWaitFrom == nil { - t.Fatalf("a retry made while the bus is silent did not wait afresh: %s %+v", p.State, p.Modules["a"]) - } -} - -func TestAPlanWithAModuleWaitingForTheBusIsOneTheLookComesBackTo(t *testing.T) { - p, _ := aTierOfThree() - if busWaiting(*p) { - t.Fatal("a plan whose modules are not asked yet waits for the bus") - } - now := time.Now() - p.Modules["b"] = &inventory.PlanModule{BusWaitFrom: &now} - p.Modules["a"] = &inventory.PlanModule{State: "asked"} - if got := busWaitingIn(*p); len(got) != 1 || got[0] != "b" { - t.Fatalf("the modules waiting for the bus are %v, not b", got) - } - p.State = inventory.PlanFailed - if busWaiting(*p) { - t.Fatal("a plan that is not open is waited for") - } -} - -func TestAModulesOwnFaultStillFailsThePlan(t *testing.T) { - asksOfEveryModule(t) - p, byName := aTierOfThree() - delete(byName, "a") - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanFailed || !strings.Contains(p.Note, "no longer in the catalogue") { - t.Fatalf("a module gone from the catalogue did not fail the plan: %s %q", p.State, p.Note) - } - - p, byName = aTierOfThree() - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - return "", errors.New("git.example/novox/a is no repository the catalogue builds a module from") - } - t.Cleanup(func() { askABuild = was }) - askModule(t.Context(), p, "a", byName) - if p.State != inventory.PlanFailed || p.Modules["a"].State != "failed" { - t.Fatalf("a module's own refusal did not fail the plan: %s %+v", p.State, p.Modules["a"]) - } -} diff --git a/cmd/mesh-controller/ask_bus_wait.go b/cmd/mesh-controller/ask_bus_wait.go index afdfe96e..4488e903 100644 --- a/cmd/mesh-controller/ask_bus_wait.go +++ b/cmd/mesh-controller/ask_bus_wait.go @@ -1,16 +1,12 @@ package main import ( - "errors" "fmt" "slices" "sort" "strings" "time" - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - "github.com/novox/mesh-controller/internal/inventory" ) @@ -18,37 +14,6 @@ import ( // (issue 1334). const busWaitBound = 10 * time.Minute -// busDidNotAnswer says an ask failed because the bus's JetStream had no responder at that moment, as it has for a few seconds -// when the bus reloads its user list (novox/hq issue 353), and not because of the module: the same ask is made again. -func busDidNotAnswer(err error) bool { - if err == nil { - return false - } - if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, nats.ErrJetStreamNotEnabled) || - errors.Is(err, jetstream.ErrJetStreamNotEnabled) || errors.Is(err, inventory.ErrNoChain) { - return true - } - // An error that crossed a boundary as text only. - text := err.Error() - return strings.Contains(text, "no responders available") || strings.Contains(text, "jetstream not enabled") -} - -// waitForTheBus leaves a module whose ask the bus did not answer unasked, the plan building, and says so in the plan's note: the -// next look asks the tier's unasked modules again. It says false, so the caller fails the plan as for any refusal, once the bus -// has been silent for busWaitBound. -func waitForTheBus(p *inventory.Plan, state *inventory.PlanModule, name string, err error, now time.Time) bool { - if state.BusWaitFrom == nil { - state.BusWaitFrom = &now - } - if now.Sub(*state.BusWaitFrom) > busWaitBound { - return false - } - state.State, state.Why = "", "" - p.Note = fmt.Sprintf("tier %d: %s not asked: the bus did not answer (%v); asked again at the next look, failing after %s", - p.Tier, name, firstLine(err.Error()), busWaitBound) - return true -} - // busWaiting says an open plan has a module of its current tier that the bus did not answer an ask of: the look must come back // for it, as nothing else will when no build of the tier is in flight (issue 1334). func busWaiting(p inventory.Plan) bool { diff --git a/cmd/mesh-controller/asker_retry_integration_test.go b/cmd/mesh-controller/asker_retry_integration_test.go index b3c6365b..5e323b58 100644 --- a/cmd/mesh-controller/asker_retry_integration_test.go +++ b/cmd/mesh-controller/asker_retry_integration_test.go @@ -33,7 +33,7 @@ func TestAWarrantForRetryRetriesAFailedPlanInTheStoreAndEndsItsCondition(t *test Note: "a failed to build in tier 0", Delivery: &inventory.PlanDelivery{}, Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1", - Why: link.KilledByHand}}, + Why: "killed by hand"}}, Times: &inventory.PlanTimes{Cut: &before, Started: &before, Ended: &ended, Class: inventory.ClassLeaf}} if err := open.inventory.SavePlan(ctx, &failed); err != nil { t.Fatal(err) diff --git a/cmd/mesh-controller/away_integration_test.go b/cmd/mesh-controller/away_integration_test.go index 1bca1e7c..c1d37e74 100644 --- a/cmd/mesh-controller/away_integration_test.go +++ b/cmd/mesh-controller/away_integration_test.go @@ -66,18 +66,8 @@ func TestAPlanGoesOnWithoutAMachineAwayWhenItsTierIsSent(t *testing.T) { // The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept. var mu sync.Mutex - var asks []string var sends [][]string - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - wasAsk := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - mu.Lock() - defer mu.Unlock() - id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond)) - asks = append(asks, source.Repository+"#"+id) - return id, nil - } - t.Cleanup(func() { askABuild = wasAsk }) + everyKeyDone(t) // the builds of the tiers: each module is taken from a done key as soon as the walk reaches it digests := 0 wasSend := sendRollout sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) { @@ -199,13 +189,7 @@ func TestAPlanGoesOnWithoutAMachineAwayWhenItsTierIsSent(t *testing.T) { if !strings.Contains(moved, "laptop was away: sent when it is heard again") || strings.Contains(moved, "anchor") { t.Errorf("1161/AC2: the plan says %q; want laptop named away and not anchor", moved) } - // The ask for tier 1's build is the next step, and records its build id on the module. - eventually(t, "tier 1 asked", func() bool { m := plan().Modules["b"]; return m != nil && m.Build != "" }) - b := plan().Modules["b"] - askedAt, _ := link.BuildAskedAt(b.Build) - record(b.Build, "b", "c2", askedAt) - register("b", "c2", askedAt) - planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build) + // Tier 1's module is taken from its key, which the walk's look does by itself. eventually(t, "tier 1 sent to anchor alone", func() bool { mu.Lock() defer mu.Unlock() diff --git a/cmd/mesh-controller/batches_test.go b/cmd/mesh-controller/batches_test.go index af52b8a9..cbbdc11b 100644 --- a/cmd/mesh-controller/batches_test.go +++ b/cmd/mesh-controller/batches_test.go @@ -264,7 +264,7 @@ func TestAPlanLineNamesWhatItMoves(t *testing.T) { } return nil } - if got, want := planLineOn(p, now, pauseView{}, tierAtLeast, running), + if got, want := planLineOn(p, now, tierAtLeast, running), "mesh-catalog #175 a tap shows its outcome · messenger, telegram → novox · tier 1 of 1, building for 2m0s"; got != want { t.Fatalf("the line reads %q, want %q", got, want) } diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index c09a49e0..c6318a61 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -2,24 +2,15 @@ package main import ( "context" - "encoding/json" "errors" "flag" "fmt" "github.com/novox/mesh-controller/internal/conditions" "io" "os" - "slices" - "strings" - "time" - - "github.com/nats-io/nats.go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // asking a build machine for a module, and what came back. @@ -28,80 +19,14 @@ import ( // cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636: // nothing in it was wrong, and no one edit was the one that should have been a new file. -// buildFrom turns what a builder said into what the mesh keeps. -// -// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder -// says `://@sha256:…`; the mesh records the artifact-store -// reference and composes the store's address back in where a reference is used. `against` — what -// the build stood on, the catalogue's edge — is recorded the same way, so an edge names a module's -// artifact and not the machine it was pulled from. -func buildFrom(result link.BuildResult) inventory.Build { - kept := inventory.Build{ - ID: result.ID, Repository: result.Repository, Ref: result.Ref, - Commit: result.Commit, On: result.On, Failed: result.Failed, - // **What the announcement carries, kept rather than discarded** (novox/hq 04-ISSUES/050). - // The catalogue turns the manifest into requires/provides edges and `against` into build - // edges, and it is not always listening when a build happens — on a fresh mesh it cannot - // be, for exactly the modules it needs most. Keeping them is what makes a replay able to - // rebuild the graph rather than a list of names. - Path: result.Path, - // What it was made from (novox/hq issue 280): two builds with one are one build. - SourceFingerprint: result.SourceFingerprint, - SourceInputs: result.SourceInputs, - // And its reuse key (novox/hq ADR 0350 §1), which the chain names (ADR 0408 §1). - ReuseKey: result.ReuseKey, - } - // When it was asked, which is what orders it against another build of the same module - // (novox/hq 04-ISSUES/219) — not when it was heard. - if asked, ok := link.BuildAskedAt(result.ID); ok { - kept.Asked = asked - } - for _, ref := range result.Against { - kept.Against = append(kept.Against, catalogue.Recorded(ref)) - } - for _, ref := range result.Mirrored { - kept.Mirrored = append(kept.Mirrored, catalogue.Recorded(ref)) - } - for _, r := range result.Read { - kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref}) - } - // What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it. - for _, s := range result.Sources { - kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths}) - } - var announced []inventory.Artifact - for _, made := range result.Made { - announced = append(announced, inventory.Artifact{ - Name: made.Name, Kind: made.Kind, Reference: made.Reference, - }) - kept.Made = append(kept.Made, inventory.Artifact{ - Name: made.Name, Kind: made.Kind, Reference: catalogue.Recorded(made.Reference), - }) - } - kept.Manifest = recordedManifest(result.Manifest, announced) - // The module name comes from the manifest, which only exists when the build got that far. - if len(kept.Manifest) > 0 { - if list, err := judgedWith(result); err == nil { - if m, err := catalogue.ParseManifestWith(kept.Manifest, list); err == nil { - kept.Module = m.Module - } - } - } - return kept -} - // buildsCommand says what has been built lately. func buildsCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("builds", flag.ContinueOnError) limit := set.Int("n", 20, "how many to show") - logOf := set.String("log", "", "a build's id: print what the build machine said, line by line") positionals, err := parseAround(set, args) if err != nil { return err } - if *logOf != "" { - return buildLog(ctx, *logOf) - } module := "" if len(positionals) == 1 { module = positionals[0] @@ -182,365 +107,6 @@ func printBuild(w io.Writer, b inventory.Build) { } } -// builderCommand issues a build machine its own broker credential. -// -// **A build machine is not a node**, and giving it a node's account would let it read another -// machine's declarations. This is narrower and different: read the build queue, write the -// exchange and an asker's reply queue, and nothing else. -// -// Issued rather than assumed, because until this the builder used whatever credential it was -// handed — which in practice meant the broker's own administrative one. A program documented as -// holding its own credential and given somebody else's is worse than one with no story at all. -func builderCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("builder issue", flag.ContinueOnError) - // Which machine will use it. Given, the credential is delivered by the mesh rather than - // printed for somebody to carry — which is the difference between the builder being a module - // and being a program somebody configures. - forNode := set.String("node", "", - "the machine that will run it, so the mesh delivers the credential instead of printing it") - module := set.String("module", "builder", "the module on that machine that will read it") - positionals, err := parseAround(set, args) - if err != nil { - return err - } - if len(positionals) != 2 || positionals[0] != "issue" { - return errors.New("builder issue [--node ]") - } - name := positionals[1] - - // **The build machine's credential is a module's credential** (novox/hq ADR 0131, design 28 - // task 5.5): minted into the mesh's records and sealed to the machine as the builder module's - // broker secret, usable at the next push — the same act `module issue` performs, and the same - // account the composed user list carries. Nothing is created on a server; the bus reads the list. - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - inv := open.inventory - shelf, err := inv.Catalogue(ctx) - if err != nil { - return err - } - m, known := shelf[*module] - if !known { - return fmt.Errorf("%s is not in the catalogue; `module add` it first", *module) - } - fmt.Printf("build machine %s: ", name) - node := *forNode - if node == "" { - entries, err := inv.Catalogued(ctx) - if err != nil { - return err - } - for _, e := range entries { - if e.Manifest.Module == *module && len(e.On) > 0 { - node = e.On[0] - } - } - } - if node == "" { - return fmt.Errorf("%s is assigned nowhere; `assign %s` first, or say --node", *module, *module) - } - address, err := broker.BusAddress() - if err != nil { - return err - } - return issueOnTheNewBus(ctx, inv, m, node, address) -} - -// recordAsked keeps a build request once it is asked (novox/hq issue 325), with who asked it, in a store -// opened for the purpose: the asks reach here from processes that hold none. -func recordAsked(ctx context.Context, r inventory.BuildRequest) { - open, err := openStores(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "build %s is asked and not kept as a build request: %v\n", r.ID, err) - return - } - defer open.Close() - recordBuildRequest(ctx, open.inventory, r) -} - -// markWaitFailed says what became of a kept build request whose waited ask failed: never handed over, so -// nothing runs; or handed over and no longer waited for, so asked with its outcome unknown — still read as in -// flight until its outcome or its bound (novox/hq issue 325). -func markWaitFailed(ctx context.Context, id string, why error) { - open, err := openStores(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "build %s could not be marked: %v\n", id, err) - return - } - defer open.Close() - mark := open.inventory.MarkOutcomeUnknown - if errors.Is(why, link.ErrNotHandedOver) { - mark = open.inventory.MarkNotAsked - } - if err := mark(ctx, id, why.Error()); err != nil { - fmt.Fprintf(os.Stderr, "build %s could not be marked: %v\n", id, err) - } -} - -// buildOneAsked is buildOne answering the id it asked with — what a plan keeps to match the outcome -// by (novox/hq ADR 0219) — and, for an ask not waited for, optionally a dry run: built and looked -// at, never taken in (issue 240), which is what `replay` asks unless told to register. -// -// asker is who asked, for the build request kept once the ask is made (novox/hq issue 325); empty for an -// asker that keeps the request itself, with its own name, once it knows the ask was made. -func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wait time.Duration, - dryRun bool, asker string) (string, error) { - if dryRun && wait != 0 { - return "", errors.New("a dry run waited for is `build --dry-run`") - } - // Before anything is asked of a builder: a source on a seat nobody holds is refused here, with - // the reason, rather than sent to a machine to fail at `git clone`. - repository, err := cloneFrom(ctx, source) - if err != nil { - return "", err - } - // Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266). - if err := verbMayAsk(ctx, source, repository, path); err != nil { - return "", err - } - - ident, err := openIdentity(ctx) - if err != nil { - return "", err - } - defer ident.Close() - - server, err := connectLink(ctx, nil, nil, nil) - if err != nil { - return "", err - } - defer server.Close() - - // Correlated by something the control plane makes, not by the module's name: two builds of one - // module can be in flight, and the second answer is not the first one's. - request := link.BuildRequest{ - ID: link.NewBuildID(time.Now()), - Repository: repository, - Path: path, - Ref: ref, - Held: heldBy(ctx), - Seats: seatBases(ctx), - DryRun: dryRun, - } - fmt.Printf("asked for %s", source) - if source.Seat != "" { - fmt.Printf(" (%s)", repository) - } - // The id is how a person follows this build while it runs: `builds --log `. - fmt.Printf(" as %s", request.ID) - if path != "" { - fmt.Printf(" at %s", path) - } - if ref != "" { - fmt.Printf(" on %s", ref) - } - fmt.Println() - - seat := buildSeatHeld(ctx) - ask, err := askOverOn(seat) - if err != nil { - return "", err - } - defer ask.Close() - fmt.Printf(" of %s\n", seat) - // Kept once it is asked, so `assign` knows a module is coming while its build runs (novox/hq issue 325); - // never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and - // is not kept. - keep := !dryRun && asker != "" - // Asked at the terminal is what lets its outcome register a module from a repository the catalogue does - // not build it from (novox/hq ADR 0266); never through a verb. - asked := inventory.BuildRequest{ID: request.ID, Repository: source.Repository, Seat: source.Seat, Path: path, - Ref: ref, For: asker, AtTerminal: startedAtTheTerminal()} - - if wait == 0 { - // Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the - // controller takes it in — records the build, registers the module — whether or not anybody - // is still here. A tool call cannot hold a connection for the minutes a build takes; it - // follows the build by its id instead. - if err := ask.Ask(ctx, request); err != nil { - return "", err - } - if keep { - recordAsked(ctx, asked) - } - if dryRun { - fmt.Printf("asked as a dry run, not waited for: `builds --log %s` follows it as it runs; "+ - "its outcome is not taken in\n", request.ID) - return request.ID, nil - } - fmt.Printf("asked, not waited for: `builds --log %s` follows it as it runs, and `builds` "+ - "shows what came of it; the module is registered when the outcome comes\n", request.ID) - return request.ID, nil - } - - // Waited for: kept while it is waited for, since that can take minutes, and marked when the hand-over - // failed (not asked) or the wait did (asked, outcome unknown) — an outcome heard later is the last word. - if keep { - recordAsked(ctx, asked) - } - result, err := ask.Submit(ctx, request, wait) - if err != nil { - if keep { - markWaitFailed(ctx, request.ID, err) - } - return request.ID, err - } - - open, err := openStores(ctx) - if err != nil { - return request.ID, err - } - defer open.Close() - manifest, kept, err := takeIn(ctx, open.inventory, result) - // The assignments pending on this build, made or ended (novox/hq issue 325). - for _, line := range settlePendingOnBuild(ctx, open, result, manifest.Module, err) { - fmt.Printf(" %s\n", line) - } - if err != nil { - return request.ID, err - } - // Said as recorded: what each artifact is, not where this builder happened to push it. - for _, made := range kept.Made { - fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference) - } - fmt.Printf("\n%s %s, built on %s from %s\n", - manifest.Module, manifest.Version, result.On, short(result.Commit)) - saysWhenThePolicyActs(ctx, open.inventory, manifest.Module) - fmt.Printf(" run `assign %s` to put it somewhere\n", manifest.Module) - return request.ID, nil -} - -// saysWhenThePolicyActs tells whoever built a module that its upgrade policy will send the -// result on at once (novox/hq issue 126, ADR 0163): a person choreographing a data move must -// know which module will not wait for them. -func saysWhenThePolicyActs(ctx context.Context, inv *inventory.Inventory, module string) { - if u, err := inv.UpgradeOf(ctx, module); err == nil && u.RollOut { - how := "one machine at a time" - if u.Together { - how = "every machine at once" - } - fmt.Printf(" %s rolls out on build: the machines running it are sent this now, %s — "+ - "`upgrade %s record` first if something must move before it does\n", module, how, module) - } -} - -// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and -// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's -// result reaches the catalogue whether or not the asker was still listening. -// -// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one -// nobody asked for, and the difference is the whole of whether somebody should be looking at -// something. Then parsed with the same parser a hand-written manifest goes through — a second path -// would be a second thing to disagree about what a manifest is — and registered with where it came -// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111), -// which the request carried and the outcome echoes. A definition naming an installation is refused -// here, where it would enter the catalogue; the build stays recorded and the refusal says which. -// -// Idempotent: the same outcome taken in twice registers the same module twice, which is one row -// written with the same values. -func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) ( - catalogue.Manifest, inventory.Build, error) { - // The list of predefined keys as the store keeps it now: another controller process may have kept a newer one. - if err := loadPredefined(ctx, inv); err != nil { - return catalogue.Manifest{}, inventory.Build{}, fmt.Errorf("the list of predefined keys kept cannot be "+ - "read: %w", err) - } - kept := buildFrom(result) - if err := inv.RecordBuild(ctx, kept); err != nil { - return catalogue.Manifest{}, kept, err - } - if result.Failed != "" { - // The builder's own words. Wrapping them in something about the control plane would put - // two explanations between a person and a build log. - return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s", - result.On, result.Repository, result.Failed) - } - // Judged first against the list the build was judged against: the catalogue's at the commit built (novox/hq - // ADR 0348 §7), so a module naming a key its own commit adds is read; and again below against the list the - // mesh publishes, before it is registered. - list, err := judgedWith(result) - if err != nil { - return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, - result.Repository, short(result.Commit), err) - } - manifest, err := catalogue.ParseManifestWith(kept.Manifest, list) - if err != nil { - return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w", - result.On, result.Repository, err) - } - recorded := inventory.Source{ - Repository: result.Repository, Path: result.Path, Ref: result.Ref, - BuiltFrom: result.Commit, Head: result.Commit, - // What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4). - Against: kept.Against, - // When it was asked, so an older request heard later does not replace a newer one - // (novox/hq 04-ISSUES/219). - Asked: kept.Asked, - } - if result.Source != nil && result.Source.Seat != "" { - recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat - } - // **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215): - // the commit is built and recorded as what it was built from, and the module keeps following - // what it followed before — the repository's default branch for one new to the catalogue. - if followedBranch(result.Ref) == "" && result.Ref != "" { - recorded.Ref = "" - if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { - recorded.Ref = followedBranch(was.Ref) - } - } - trunk := result.Trunk - if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" { - trunk = followedBranch(was.Ref) - } - if trunk == "" { - trunk = "main" - } - j := takenBuild{kept: kept, manifest: manifest, recorded: recorded, ask: byBuild(result.ID), path: result.Path, - trunk: trunk, by: result.On, what: result.Repository, commit: result.Commit} - // The checks only an outcome of build-agent needs: a key is a trunk build by construction (ADR 0501 §2), in no - // scratch namespace, and carries no list of its own. - j.published = func() error { - // **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's - // default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay - // --register` of one — is for checking, and is never a module's version; nothing could then send it. - // The branch the module already follows is its trunk — never the branch a build was asked at, or a - // build of a feature branch by name would make that branch its trunk. - follows := "" - if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { - follows = followedBranch(was.Ref) - } - if err := publishable(result, follows); err != nil { - return err - } - // **Never a build in the scratch namespace** (novox/hq ADR 0239 §9, ADR 0350 §2): what a check builds is - // registered only as the walk's copy in the module's own namespace. - if scratch := inScratch(result.Made); scratch != "" { - return fmt.Errorf("%s is in the scratch namespace, which only a walk's copy leaves (ADR 0350)", scratch) - } - return nil - } - j.carried = func() error { - // **The newest trunk build's list of predefined keys is published** (novox/hq ADR 0348 §7): a build of the - // catalogue that may be registered keeps its list when its commit is newer than the one kept. - carried, err := carriedList(result) - if err != nil { - return err - } - if carried != nil { - if err := publishPredefined(ctx, inv, result, carried); err != nil { - fmt.Printf("%s: the list of predefined keys it carried is not published: %v\n", result.ID, err) - } - } - return nil - } - manifest, err = registerJudged(ctx, inv, j) - return manifest, kept, err -} - // takenBuild is a build the controller judges and registers, whatever told it of the build: build-agent's outcome (the // builder's own module's, until R4g, ADR 0453 §5) or the builder's done key (ADR 0501 §1). **One judging for both**: // the rules of who may register, the gate a build failed, the predefined keys and the rule that an unchanged build @@ -643,44 +209,6 @@ func registerJudged(ctx context.Context, inv *inventory.Inventory, j takenBuild) return manifest, nil } -// inScratch is the first artifact a build made into the scratch namespace; empty when none is. -func inScratch(made []link.MadeArtifact) string { - for _, m := range made { - if path, kept := catalogue.InArtifactStore(catalogue.Recorded(m.Reference)); kept && strings.HasPrefix(path, builder.ScratchPrefix) { - return m.Reference - } - } - return "" -} - -// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published. -var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " + - "never published (ADR 0238) — merge it, and the merge builds it") - -// publishable says whether a build's outcome may become a module's version: its commit on the module's -// trunk, as the build seat read the forge at the build — the branch the module follows when its source -// names one, else its repository's default branch. A build seat that could not say — one older than the -// rule, building its own successor — is let through and said, so the rule can reach the mesh. -func publishable(result link.BuildResult, follows string) error { - if result.Check != nil || result.Checked != nil || result.DryRun { - return errors.New("a check or a dry run is never published") - } - if result.Trunk == "" { - fmt.Printf("%s: the build seat did not say whether %s is on its repository's trunk (it predates ADR 0238); "+ - "registered as before\n", result.ID, short(result.Commit)) - return nil - } - trunk := result.Trunk - if follows != "" { - trunk = follows - } - on := slices.Contains(result.Branches, trunk) || (trunk == result.Trunk && result.OnTrunk) - if !on { - return fmt.Errorf("%w (%s is not on %s)", errOffTheTrunk, short(result.Commit), trunk) - } - return nil -} - // answers is what the three questions came back with, read once. type answers struct { // notAnnounced are the owners some module reads the state of that have announced nothing yet (novox/hq ADR 0495, @@ -703,8 +231,6 @@ type answers struct { reported []inventory.Reported // plans is what the last merges produced and where each stands (novox/hq ADR 0162). plans []inventory.Plan - // paused is whether the build seat takes work, which a plan waiting on it says (ADR 0219). - paused pauseView // tierBounds is how long each repository's plan tier may take before it is late (novox/hq issue 296). tierBounds tierBounds // refused is why a machine cannot be worked out at all, by name. A different thing from every @@ -766,185 +292,3 @@ type answers struct { lastSent map[string]inventory.Send lastSentUnread string } - -// heldBy is every artifact this mesh has built, for a build that may need one as its base. -// -// **A failure here is not a failure to build.** A module that names no base does not need this at -// all, and one that does gets a refusal naming exactly what is missing — which is a better sentence -// than a build command refusing to start because a query did not run. So the store not opening is -// reported and the build goes ahead without it. -// -// 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 "/", 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 { - fmt.Fprintf(os.Stderr, "could not read what this mesh has built, so a module naming a "+ - "base will be told that base is missing: %v\n", err) - // empty-on-error: said above; a build that names a base is refused by name for want of it - return nil - } - defer open.Close() - held, err := open.inventory.Held(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read what this mesh has built: %v\n", err) - // empty-on-error: said above; a build that names a base is refused by name for want of it - return nil - } - address, err := whereABuilderReachesTheStore(ctx, open.inventory) - if err != nil { - fmt.Fprintf(os.Stderr, "could not find the artifact store on this mesh's network, so a "+ - "module naming a base will be handed a reference nothing can fetch: %v\n", err) - return held - } - if address == "" { - return held - } - routed := make(map[string]string, len(held)) - for repository, reference := range held { - routed[repository] = catalogue.Rerouted(reference, address) - } - return routed -} - -// askOver opens the way a build is asked for, on whichever bus the mesh is on. -// -// **One place chooses**, as everywhere else the bus change went (novox/hq ADR 0116 step 5). On the bus -// the mesh runs on today this needs the controller's own connection, so it is handed one; on the bus -// being built it dials, because a build request is a one-shot and holds nothing else. -func askOverOn(seat string) (link.Builders, error) { - // The serving controller asks on its own connection (novox/hq issue 327). - if serving := servingBus.Load(); serving != nil { - return link.BuildsOn(serving, seat), nil - } - address, err := broker.BusAddress() - if err != nil { - return nil, err - } - return link.BuildsOverNATSOn(address, seat) -} - -// buildSeatHeld is the build role to ask: the one some assigned module claims (novox/hq ADR 0190, -// the handover). Read from the catalogue at ask time, because the answer changes exactly once, the -// moment the first build-agent is assigned — and a controller that asked the new role before then -// would queue work nothing takes, while the outcome that registers build-agent itself has to come -// from the old builder. When the catalogue cannot be read the current role is asked, said aloud. -func buildSeatHeld(ctx context.Context) string { - open, err := openStores(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read what is assigned, so the build is asked of %s: %v\n", - link.TheBuildMachine, err) - return link.TheBuildMachine - } - defer open.Close() - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read the catalogue, so the build is asked of %s: %v\n", - link.TheBuildMachine, err) - return link.TheBuildMachine - } - return buildSeatAmong(entries) -} - -// buildSeatAmong is the rule, over what the catalogue holds: the current build role when any -// assigned module claims it; else the retired role while an assigned module still claims that; else -// the current role, which is where every ask goes once the handover is done. -func buildSeatAmong(entries []inventory.Entry) string { - heldBefore := false - for _, e := range entries { - if len(e.On) == 0 { - continue - } - if e.Manifest.ClaimsSeat(link.TheBuildMachine) { - return link.TheBuildMachine - } - if e.Manifest.ClaimsSeat(link.TheBuildMachineBefore) { - heldBefore = true - } - } - if heldBefore { - return link.TheBuildMachineBefore - } - return link.TheBuildMachine -} - -// buildLog prints everything a build machine said about one build, read back from the bus. -// -// **From the stream, not from a record** (novox/hq ADR 0157). A build's lines are the role's own -// events under the build's id, retained with every other event; the mesh keeps no second copy. Read -// with a consumer of its own that is gone when this returns, so nothing accumulates in the server -// for the reading, and filtered by subject, so one build's lines are all that travel. -func buildLog(ctx context.Context, id string) error { - js, err := aBus() - if err != nil { - return fmt.Errorf("cannot reach the bus to read a build's log: %w", err) - } - defer js.Close() - - // Under whichever build role did it: a build asked of the retired role during the handover - // (ADR 0190) said its lines as that role's events, and a reader should not have to know which. - lines := link.BuildLogOf("*", id) - sub, err := js.Context().PullSubscribe(lines, "", - nats.BindStream(broker.EventsStream), nats.DeliverAll(), nats.AckNone()) - if err != nil { - return fmt.Errorf("cannot read %s from the bus: %w", lines, err) - } - defer func() { _ = sub.Unsubscribe() }() - - printed := 0 - for { - batch, err := sub.Fetch(200, nats.MaxWait(2*time.Second)) - if err != nil && !errors.Is(err, nats.ErrTimeout) && !errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("reading a build's log: %w", err) - } - for _, msg := range batch { - var line link.BuildLine - if err := json.Unmarshal(msg.Data, &line); err != nil { - fmt.Printf(" ? %s\n", string(msg.Data)) - continue - } - at := line.At - if t, err := time.Parse(time.RFC3339Nano, line.At); err == nil { - at = t.Local().Format("15:04:05") - } - fmt.Printf("%s %4d [%s] %s\n", at, line.Seq, line.Step, line.Message) - printed++ - } - if len(batch) < 200 { - break - } - } - if printed == 0 { - fmt.Printf("nothing on the bus for build %s: no build by that id in the last week, or a build "+ - "machine older than this that said nothing while building\n", id) - } - return nil -} diff --git a/cmd/mesh-controller/build_seat_test.go b/cmd/mesh-controller/build_seat_test.go deleted file mode 100644 index 26e5942d..00000000 --- a/cmd/mesh-controller/build_seat_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -func claiming(module, seat string, on ...string) inventory.Entry { - return inventory.Entry{ - Manifest: catalogue.Manifest{Module: module, Claims: []catalogue.Claim{{Name: seat}}}, - On: on, - } -} - -// The controller asks the build role that has a holder (novox/hq ADR 0190 handover): the retired -// one while only the builder is assigned, the current one from the first build-agent on, and the -// current one when nothing holds either — where every ask goes once the handover is done. -func TestTheControllerAsksTheBuildRoleThatHasAHolder(t *testing.T) { - onlyTheBuilder := []inventory.Entry{ - claiming("builder", link.TheBuildMachineBefore, "anchor"), - claiming("build-agent", link.TheBuildMachine), // registered, assigned nowhere yet - } - if got := buildSeatAmong(onlyTheBuilder); got != link.TheBuildMachineBefore { - t.Errorf("with only the builder assigned, asked %q", got) - } - bothHeld := []inventory.Entry{ - claiming("builder", link.TheBuildMachineBefore, "anchor"), - claiming("build-agent", link.TheBuildMachine, "home-server"), - } - if got := buildSeatAmong(bothHeld); got != link.TheBuildMachine { - t.Errorf("with a build-agent assigned anywhere, asked %q", got) - } - neither := []inventory.Entry{claiming("builder", link.TheBuildMachineBefore)} - if got := buildSeatAmong(neither); got != link.TheBuildMachine { - t.Errorf("with no holder of either, asked %q, want the current role", got) - } - if got := buildSeatAmong(nil); got != link.TheBuildMachine { - t.Errorf("an empty catalogue asks %q", got) - } -} diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go index 82f29a7b..619aef0c 100644 --- a/cmd/mesh-controller/build_source.go +++ b/cmd/mesh-controller/build_source.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "regexp" + "slices" "strings" "time" @@ -188,25 +189,13 @@ func (f *sourceForms) same(aRepository, aSeat, bRepository, bSeat string) bool { return f.canonical(aRepository, aSeat) == f.canonical(bRepository, bSeat) } -// buildsFrom says the catalogue builds some module from this repository. -func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat string) bool { - for _, e := range entries { - if e.Provided || e.Source.Repository == "" { - continue - } - if f.same(e.Source.Repository, e.Source.Seat, repository, seat) { - return true - } - } - return false -} - // forgeFacts is what the mesh's forge says of a repository a module is registered from (novox/hq ADR 0266). type forgeFacts struct { // ID is the forge's own id of the repository: what tells it from one deleted and made again by its name. ID int64 - // Guarded is whether the branch is protected as a module's trunk must be: no direct push, at least one - // required status, and no administrator merging past one. Why says what is missing when it is not. + // Guarded is whether the branch is protected as a module's trunk must be: no direct push, a merge whitelist naming + // the tools' account and no agent's account, and no administrator merging past the rule (novox/hq ADR 0534 §6; no + // required status, which the forge no longer asks, §1). Why says what is missing when it is not. Guarded bool Why string } @@ -361,27 +350,48 @@ func askTheForgeOnce(ctx context.Context, owner, repo, branch string) (forgeFact return facts, nil } +// forgeToolsAccount is the forge tools' account, which main's merge whitelist names for mesh-delivery (novox/hq ADR 0398 +// §2, the gitea module's tools-account.ts TOOLS_ACCOUNT); beside it the whitelist names only people's own logins, the +// operator's (ADR 0534 §2), never an agent's account. +const forgeToolsAccount = "mesh-tools" + +// isAgentAccount is whether a forge login is a node's agent's account, agent- (novox/hq ADR 0398 §1): one that +// may never merge into a module's trunk by itself. +func isAgentAccount(login string) bool { return strings.HasPrefix(login, "agent-") } + // protectionRule is a branch protection rule as the forge's tool summarises it. type protectionRule struct { Rule string `json:"rule"` Push bool `json:"push"` RequiredStatuses []string `json:"required_statuses"` AdminMayOverride bool `json:"admin_may_override"` + // MergeWhitelist is who may merge into the branch; nil when the rule keeps no merge whitelist (anyone who writes). + MergeWhitelist []string `json:"merge_whitelist"` } // judgedRule judges the one rule the forge applies to a branch, as the forge picks it: the rule named for the // branch, else the first glob rule, in the forge's order, that covers it (gitea's first matching rule). Never a -// later rule that happens to be stronger: the forge does not read it. +// later rule that happens to be stronger: the forge does not read it. A trunk is trusted for who can land a commit on +// it, not for a status it requires (novox/hq ADR 0534 §6): no push, a merge whitelist naming the tools' account and +// beside it only people's own logins, never an agent's, and no administrator merging past the rule; then a commit lands +// there through mesh-delivery, on a passing mesh/check and the operator's Approve (ADR 0534 §4), or by the operator's +// own hand at the forge (§2). func judgedRule(rules []protectionRule, branch string) forgeFacts { switch r := appliedRule(rules, branch); { case r == nil: return forgeFacts{Why: fmt.Sprintf("no protection rule covers %s", branch)} case r.Push: return forgeFacts{Why: fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch)} - case len(r.RequiredStatuses) == 0: - return forgeFacts{Why: fmt.Sprintf("the rule %s requires no status before a merge into %s", r.Rule, branch)} + case r.MergeWhitelist == nil || len(r.MergeWhitelist) == 0: + return forgeFacts{Why: fmt.Sprintf("the rule %s keeps no merge whitelist naming %s alone for %s", r.Rule, forgeToolsAccount, branch)} + case !slices.Contains(r.MergeWhitelist, forgeToolsAccount): + return forgeFacts{Why: fmt.Sprintf("the rule %s lets %s merge into %s, and not %s", r.Rule, + strings.Join(r.MergeWhitelist, ", "), branch, forgeToolsAccount)} + case slices.ContainsFunc(r.MergeWhitelist, isAgentAccount): + return forgeFacts{Why: fmt.Sprintf("the rule %s lets an agent's account merge into %s: %s", r.Rule, branch, + strings.Join(r.MergeWhitelist, ", "))} case r.AdminMayOverride: - return forgeFacts{Why: fmt.Sprintf("the rule %s lets an administrator merge into %s past a status", r.Rule, branch)} + return forgeFacts{Why: fmt.Sprintf("the rule %s lets an administrator merge into %s past it", r.Rule, branch)} } return forgeFacts{Guarded: true} } @@ -464,7 +474,7 @@ func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, o // - from the module's registered repository — the same repository by the forge's own id, not only its name; or, // for a module new to the catalogue, from a repository the catalogue builds another module from; // - and from a repository on the mesh's forge whose trunk is protected as a trunk must be: no direct push, at -// least one required status, no administrator merging past one. +// a merge whitelist naming the tools' account and no agent's, no administrator merging past the rule (ADR 0534 §6). // // Anything else only when the build request was kept as asked at the controller's terminal, for this very // repository and path; or, for the build of a merge, when mesh-delivery announced it on the operator's Approve of @@ -489,9 +499,6 @@ type registrationAsk struct { Terminal bool } -// byBuild is the warrant of a build's outcome: read from the request kept under its id. -func byBuild(id string) registrationAsk { return registrationAsk{Build: id} } - // String is the ask as the registration's words name it. func (a registrationAsk) String() string { switch { @@ -697,29 +704,3 @@ func sourceWords(repository, seat string) string { } return buildSource{Repository: repository, Seat: seat}.String() } - -// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from -// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be -// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked. -func verbMayAsk(ctx context.Context, source buildSource, url, path string) error { - if startedAtTheTerminal() { - return nil - } - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - return err - } - forms := newSourceForms(ctx, open.inventory) - if forms.buildsFrom(entries, source.Repository, source.Seat) || forms.buildsFrom(entries, url, "") { - return nil - } - return terminalRefusal("%s is no repository the catalogue builds a module from, and a build of any other is "+ - "asked at the controller's terminal only, never through a verb: a build node runs what the repository "+ - "says, and its outcome would register a module the next push sends — whoever may call a verb includes "+ - "agents (novox/hq ADR 0266). Nothing was asked", source) -} diff --git a/cmd/mesh-controller/build_source_integration_test.go b/cmd/mesh-controller/build_source_integration_test.go index eadf9ade..66645552 100644 --- a/cmd/mesh-controller/build_source_integration_test.go +++ b/cmd/mesh-controller/build_source_integration_test.go @@ -129,27 +129,6 @@ func TestADeliveryFromTheRegisteredRepositoryIsRegistered(t *testing.T) { } } -func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T) { - theCatalogue(t) - ctx := t.Context() - t.Setenv(verbVar, "build") - t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat") - err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git", "") - var policy *heldAtTheTerminal - if !errors.As(err, &policy) { - t.Fatalf("a verb's build of an agent's repository was asked: %v", err) - } - if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, - "http://forge.internal:20000/novox/mesh-catalog.git", ""); err != nil { - t.Fatalf("a verb's build of the catalogue repository was refused: %v", err) - } - t.Setenv(verbVar, "") - t.Setenv(link.CallerVar, "") - if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "", ""); err != nil { - t.Fatalf("the terminal was refused: %v", err) - } -} - func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { open := theCatalogue(t) ctx := t.Context() diff --git a/cmd/mesh-controller/build_source_test.go b/cmd/mesh-controller/build_source_test.go index 1f692d8b..7a3a7eff 100644 --- a/cmd/mesh-controller/build_source_test.go +++ b/cmd/mesh-controller/build_source_test.go @@ -102,16 +102,17 @@ func init() { // The forge applies one rule to a branch: the rule named for it, else the first glob that covers it. A stronger // rule later in the list is not what guards the branch, and is not read as if it were (the confirmation review). func TestTheRuleJudgedIsTheOneTheForgeApplies(t *testing.T) { - guarded := protectionRule{Rule: "*", RequiredStatuses: []string{"mesh/merge-gate"}} - open := protectionRule{Rule: "main", Push: true, RequiredStatuses: []string{"mesh/merge-gate"}} + tools := []string{forgeToolsAccount} + guarded := protectionRule{Rule: "*", MergeWhitelist: tools} + open := protectionRule{Rule: "main", Push: true, MergeWhitelist: tools} if f := judgedRule([]protectionRule{guarded, open}, "main"); f.Guarded { t.Error("an exact rule letting pushes was passed over for a glob that guards") } - if f := judgedRule([]protectionRule{{Rule: "ma*", RequiredStatuses: nil}, {Rule: "*", RequiredStatuses: []string{"x"}}}, + if f := judgedRule([]protectionRule{{Rule: "ma*", MergeWhitelist: nil}, {Rule: "*", MergeWhitelist: tools}}, "main"); f.Guarded || !strings.Contains(f.Why, "ma*") { t.Errorf("the first glob covering the branch was not the one judged: %+v", f) } - if f := judgedRule([]protectionRule{{Rule: "release/*"}, {Rule: "main", RequiredStatuses: []string{"x"}}}, "main"); !f.Guarded { + if f := judgedRule([]protectionRule{{Rule: "release/*"}, {Rule: "main", MergeWhitelist: tools}}, "main"); !f.Guarded { t.Errorf("the rule named for the branch was not judged: %+v", f) } if f := judgedRule(nil, "main"); f.Guarded { @@ -119,6 +120,39 @@ func TestTheRuleJudgedIsTheOneTheForgeApplies(t *testing.T) { } } +// **A module's trunk is trusted when it takes no push, only the tools' account and people's own logins merge into it, +// and no administrator merges past its rule** (novox/hq ADR 0534 §2, §6): the forge requires no status to merge (§1), so +// a rule requiring none is trusted; one with no merge whitelist, no tools' account in it, an agent's account in it, a +// push allowed or an administrator's override is not. +func TestATrunkIsTrustedForItsMergersNotForAStatus(t *testing.T) { + tools := []string{forgeToolsAccount} + for _, c := range []struct { + name string + rule protectionRule + want bool + why string + }{ + {"no status, no push, the tools alone merge", protectionRule{Rule: "main", MergeWhitelist: tools}, true, ""}, + {"a status required as well", protectionRule{Rule: "main", MergeWhitelist: tools, RequiredStatuses: []string{"mesh/check"}}, true, ""}, + {"no merge whitelist", protectionRule{Rule: "main"}, false, "merge whitelist"}, + {"an empty merge whitelist", protectionRule{Rule: "main", MergeWhitelist: []string{}}, false, "merge whitelist"}, + {"the operator's own login beside the tools", protectionRule{Rule: "main", MergeWhitelist: []string{forgeToolsAccount, "jschoubben"}}, true, ""}, + {"an agent's account in the whitelist", protectionRule{Rule: "main", MergeWhitelist: []string{forgeToolsAccount, "agent-ace"}}, false, "agent-ace"}, + {"an agent's account alone", protectionRule{Rule: "main", MergeWhitelist: []string{"agent-ace"}}, false, "agent-ace"}, + {"a person's login without the tools", protectionRule{Rule: "main", MergeWhitelist: []string{"jschoubben"}}, false, forgeToolsAccount}, + {"a push allowed", protectionRule{Rule: "main", Push: true, MergeWhitelist: tools}, false, "push"}, + {"an administrator's override", protectionRule{Rule: "main", MergeWhitelist: tools, AdminMayOverride: true}, false, "administrator"}, + } { + f := judgedRule([]protectionRule{c.rule}, "main") + if f.Guarded != c.want || !strings.Contains(f.Why, c.why) { + t.Errorf("%s: %+v, want guarded %v saying %q", c.name, f, c.want, c.why) + } + if strings.Contains(f.Why, "requires no status") { + t.Errorf("%s: a trunk is still judged on a required status: %s", c.name, f.Why) + } + } +} + // An id the forge could not give keeps the id already recorded. // **The operator's Approve of a reviewed group registers a moved module from its new repository** (novox/hq ADR 0420 §7, diff --git a/cmd/mesh-controller/build_test.go b/cmd/mesh-controller/build_test.go index 434f961b..ab1bf2e7 100644 --- a/cmd/mesh-controller/build_test.go +++ b/cmd/mesh-controller/build_test.go @@ -2,12 +2,10 @@ package main import ( "bytes" - "reflect" "strings" "testing" "time" - sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -68,12 +66,3 @@ func TestBuildsSaysTheSourceFingerprint(t *testing.T) { t.Errorf("the other lines changed:\n%s\nwant\n%s", plain.String(), want) } } - -// 1215/AC4: what the builder said of the inputs is what the controller keeps of the build. -func TestTheControllerKeepsTheInputsABuilderSaid(t *testing.T) { - in := &sdkdelivery.SourceInputs{Module: "mailu", Tree: "t", Toolchains: map[string]string{"code": "ts sha256:aa 01"}} - kept := buildFrom(link.BuildResult{ID: "build-1", Repository: "r", Commit: "c", SourceFingerprint: "src1:x", SourceInputs: in}) - if !reflect.DeepEqual(kept.SourceInputs, in) { - t.Errorf("the controller keeps %+v, the builder said %+v", kept.SourceInputs, in) - } -} diff --git a/cmd/mesh-controller/builder_on_the_bus_test.go b/cmd/mesh-controller/builder_on_the_bus_test.go index b17ff06e..4ab796b6 100644 --- a/cmd/mesh-controller/builder_on_the_bus_test.go +++ b/cmd/mesh-controller/builder_on_the_bus_test.go @@ -4,7 +4,6 @@ package main import ( "context" - "sync" "testing" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" @@ -106,34 +105,5 @@ func (b builderOnTheBus) built(t *testing.T, module, version, fp, manifest, stat } } -// noBuildAsked fails the test if anything asks build-agent for a build (ADR 0501 §6, ADR 0503 §6). -func noBuildAsked(t *testing.T) { - t.Helper() - was := askABuild - askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) { - t.Errorf("a build was asked of build-agent, at %q", path) - return "", context.Canceled - } - t.Cleanup(func() { askABuild = was }) -} - -// asksWithPaths records every build a merge asks for, as repository, path and ref. The merge window's goroutine and -// the test's own may ask at once, so the record is written under a lock (issue 1543). -func asksWithPaths(t *testing.T) *[][3]string { - t.Helper() - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): these tests are of what a walk does around it - var asked [][3]string - var mu sync.Mutex - was := askABuild - askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) { - mu.Lock() - defer mu.Unlock() - asked = append(asked, [3]string{source.Repository, path, ref}) - return "b-" + path, nil - } - t.Cleanup(func() { askABuild = was }) - return &asked -} - // theIndexedModule is the look at the indexer as the controller makes it, kept before any test replaces it. var theIndexedModule = indexedModule diff --git a/cmd/mesh-controller/builder_says_default_test.go b/cmd/mesh-controller/builder_says_default_test.go deleted file mode 100644 index 744970e7..00000000 --- a/cmd/mesh-controller/builder_says_default_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import "context" - -// A test's builder says it writes references, as R4e's does, unless the test says otherwise (issue 1886): no test reads -// the bus for it. -func init() { - builderSaysReferences = func(context.Context) (bool, error) { return true, nil } -} diff --git a/cmd/mesh-controller/builder_says_test.go b/cmd/mesh-controller/builder_says_test.go deleted file mode 100644 index c85bdc17..00000000 --- a/cmd/mesh-controller/builder_says_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package main - -import ( - "context" - "errors" - "strings" - "testing" - - sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" - - "github.com/novox/mesh-controller/internal/builderkeys" - "github.com/novox/mesh-controller/internal/inventory" -) - -// theBuilderSays sets what the running builder says for one test. -func theBuilderSays(t *testing.T, says bool, err error) { - t.Helper() - was := builderSaysReferences - builderSaysReferences = func(context.Context) (bool, error) { return says, err } - t.Cleanup(func() { builderSaysReferences = was }) -} - -// askingBuildAgent records the modules build-agent is asked for, by path. -func askingBuildAgent(t *testing.T) *[]string { - t.Helper() - var asked []string - was := askABuild - askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) { - asked = append(asked, path) - return "build-9", nil - } - t.Cleanup(func() { askABuild = was }) - return &asked -} - -// 1886/AC2: while no running builder says it writes references, a walk asks build-agent for a module of its tier, as -// before R4e, and takes nothing from the builder's key. -func TestAWalkAsksBuildAgentWhileTheBuilderDoesNotSayItWritesReferences(t *testing.T) { - theBuilderSays(t, false, nil) - asked := askingBuildAgent(t) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - s := p.Modules["sudo"] - if s.Key || s.Build != "build-9" || s.State != "asked" || len(*asked) != 1 { - t.Fatalf("the module is not asked of build-agent: %+v, asked %v", s, *asked) - } -} - -// 1886/AC3: once a running builder says it writes references, a walk takes the module from the builder's key (ADR -// 0498 §8) and asks build-agent for nothing. -func TestAWalkTakesTheKeyOnceTheBuilderSaysItWritesReferences(t *testing.T) { - theBuilderSays(t, true, nil) - asked := askingBuildAgent(t) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - if s := p.Modules["sudo"]; !s.Key || s.Build != "" || len(*asked) != 0 { - t.Fatalf("the module is not taken from its key: %+v, asked %v", s, *asked) - } -} - -// 1886/AC2: a say that cannot be read leaves the module unasked and says why, so the next look asks it: neither path is -// guessed. -func TestASayNotReadLeavesTheModuleUnasked(t *testing.T) { - theBuilderSays(t, false, errors.New("the bus did not answer")) - asked := askingBuildAgent(t) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - s := p.Modules["sudo"] - if s.State != "" || s.Key || len(*asked) != 0 || !strings.Contains(s.Why, "the bus did not answer") { - t.Fatalf("a say not read: %+v, asked %v", s, *asked) - } - if got := unaskedOf(*p); len(got) != 1 { - t.Fatalf("the module is not left for the next look to ask: %v", got) - } -} - -// 1886/AC2: the one function every ask goes through lets build-agent build any module while the builder does not say -// it writes references, and refuses it again once it does. -func TestBuildAgentIsAskedForAnyModuleWhileTheBuilderDoesNotSay(t *testing.T) { - was := askedModuleOf - t.Cleanup(func() { askedModuleOf = was }) - askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) { - return strings.TrimPrefix(path, "modules/"), nil - } - theBuilderSays(t, false, nil) - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, "modules/sudo"); err != nil { - t.Fatalf("an ask while the builder does not say is refused: %v", err) - } - theBuilderSays(t, true, nil) - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, "modules/sudo"); !errors.Is(err, errNotTheBuildersOwn) { - t.Fatalf("an ask once the builder says is not refused: %v", err) - } -} - -// 1886/AC4: an open walk that waits on a key the running builder cannot reference (done without a reference, as R4e's -// own walk did in plan-1792707174627079192) is judged again at the next look: the module is handed back unasked, and -// that look's ask goes to build-agent. With the builder saying it writes references, the same walk keeps waiting. -func TestAnOpenWalkWaitingOnAnUnreferencedKeyIsAskedOfBuildAgent(t *testing.T) { - t.Cleanup(func() { - keyTakes.Lock() - keyTakes.running, keyTakes.waits, keyTakes.taken = map[string]bool{}, map[string]string{}, map[string]bool{} - keyTakes.Unlock() - }) - fp := "src2:" + strings.Repeat("e", 64) - unreferenced := builderkeys.Key{Module: "sudo", Fingerprint: fp, Found: true, Revision: 1, - Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, Manifest: strings.Repeat("b", 64), - Artifacts: []sdkdelivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: "sha256:1"}}}} - walk := func() (*inventory.Plan, map[string]inventory.Entry, planKeysOf) { - theBuilderSays(t, true, nil) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}}, - keys: map[string]builderkeys.Key{"sudo": unreferenced}} - return p, byName, keys - } - take := func(string, sdkdelivery.Module, string) { t.Fatal("an unreferenced key was taken in") } - records := recordsOf{builds: map[string]inventory.Build{}} - - // Said: it waits for the reference, as ADR 0498 §8 has it. - p, _, keys := walk() - if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take); err != nil { - t.Fatal(err) - } - if s := p.Modules["sudo"]; s.State != "asked" || !s.Key || !strings.Contains(s.Why, "waits for the reference") { - t.Fatalf("with the builder saying, the walk does not wait for the reference: %+v", s) - } - - // Not said: handed back, and the next look asks build-agent. - p, byName, keys := walk() - theBuilderSays(t, false, nil) - asked := askingBuildAgent(t) - changed, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take) - if err != nil || !changed { - t.Fatalf("the walk was not judged again: %v %v", changed, err) - } - s := p.Modules["sudo"] - if s.Key || s.State != "" || p.State != inventory.PlanBuilding || !strings.Contains(s.Why, "does not say") { - t.Fatalf("the module is not handed back to be asked of build-agent: %+v %s", s, p.State) - } - for _, m := range unaskedOf(*p) { - askModule(t.Context(), p, m, byName) - } - if s := p.Modules["sudo"]; s.Key || s.Build != "build-9" || len(*asked) != 1 { - t.Fatalf("the next look did not ask build-agent: %+v, asked %v", s, *asked) - } -} - -// 1886/AC4: a key the running builder failed while it does not say it writes references fails no walk: the module is -// asked of build-agent, whose build is the one delivered. -func TestAFailedKeyOfABuilderThatDoesNotSayFailsNoWalk(t *testing.T) { - theBuilderSays(t, true, nil) - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - _ = byName - fp := "src2:" + strings.Repeat("f", 64) - theBuilderSays(t, false, nil) - keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}}, - keys: map[string]builderkeys.Key{"sudo": {Module: "sudo", Fingerprint: fp, Found: true, - Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildFailed, Error: "not stored: tools: no reference"}}}} - if _, err := settleKeys(t.Context(), recordsOf{}, keys, p, p.Tiers[0], func(string, sdkdelivery.Module, string) {}); err != nil { - t.Fatal(err) - } - if s := p.Modules["sudo"]; p.State != inventory.PlanBuilding || s.State != "" || s.Key { - t.Fatalf("a failed key of a builder that does not say failed the walk: %s %q %+v", p.State, p.Note, s) - } -} - -// 1886/AC6 (ADR 0522 §6): the parts that write references, the builder and the build modules, are build-agent's own until -// R4g: asked of build-agent and never taken from keys they write themselves, whatever the builder says. -func TestTheReferenceWritersAreAskedOfBuildAgentWhateverTheBuilderSays(t *testing.T) { - theBuilderSays(t, true, nil) - asked := askingBuildAgent(t) - for _, m := range []string{"mesh-build-go", "mesh-build-node", "mesh-build-image", "mesh-delivery-builder"} { - p, byName := aWalkOfOne(m) - askModule(t.Context(), p, m, byName) - if s := p.Modules[m]; s.Key || s.Build != "build-9" { - t.Errorf("%s is not asked of build-agent: %+v", m, s) - } - } - if len(*asked) != 4 { - t.Fatalf("build-agent was asked %v", *asked) - } -} - -// 1886 review round 1 (ADR 0522 §2): the guard of the one ask function judges by the say askModule read, never a second -// read: a second read that fails, or that says otherwise, fails no plan. -func TestTheAskGuardUsesTheSayTheWalkRead(t *testing.T) { - was := askedModuleOf - t.Cleanup(func() { askedModuleOf = was }) - askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) { - return strings.TrimPrefix(path, "modules/"), nil - } - reads := 0 - wasSays := builderSaysReferences - t.Cleanup(func() { builderSaysReferences = wasSays }) - for _, second := range []struct { - name string - says bool - err error - }{{"a second read that fails", false, errors.New("the bus did not answer")}, {"a say that turned true", true, nil}} { - reads = 0 - builderSaysReferences = func(context.Context) (bool, error) { - reads++ - if reads == 1 { - return false, nil - } - return second.says, second.err - } - wasAsk := askABuild - askABuild = func(ctx context.Context, source buildSource, path, _ string) (string, error) { - if err := onlyTheBuildersOwn(ctx, source, path); err != nil { - return "", err - } - return "build-9", nil - } - p, byName := aWalkOfOne("sudo") - askModule(t.Context(), p, "sudo", byName) - askABuild = wasAsk - if s := p.Modules["sudo"]; p.State == inventory.PlanFailed || s.Build != "build-9" || s.Key { - t.Errorf("%s: the walk %s, the module %+v", second.name, p.State, s) - } - } -} - -// 1886 merge check hang: the look must judge an ask and a settle by one say. A settle that read the say from another -// source than the ask (the bus's bucket, against the walk's read) handed a module back, the next ask took it from its key -// again, and the look's loop never ended: walk_from_keys_integration_test hung. -func TestTheSettleJudgesByTheSameSayAsTheAsk(t *testing.T) { - theBuilderSays(t, true, nil) - p, _ := aWalkOfOne("sudo") - p.Modules["sudo"] = &inventory.PlanModule{} - askModule(t.Context(), p, "sudo", map[string]inventory.Entry{"sudo": {Source: inventory.Source{Repository: "novox/mesh-catalog", Path: "modules/sudo", Ref: "main"}}}) - fp := "src2:" + strings.Repeat("1", 64) - keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}}, - keys: map[string]builderkeys.Key{"sudo": {Module: "sudo", Fingerprint: fp, Found: true, - Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildBuilding, By: "shanks"}}}} - if _, err := settleKeys(t.Context(), recordsOf{}, keys, p, p.Tiers[0], func(string, sdkdelivery.Module, string) {}); err != nil { - t.Fatal(err) - } - if s := p.Modules["sudo"]; !s.Key || s.State != "asked" { - t.Fatalf("the settle judged by another say than the ask's: %+v", s) - } -} diff --git a/cmd/mesh-controller/builders_own_test.go b/cmd/mesh-controller/builders_own_test.go deleted file mode 100644 index 55aabaa9..00000000 --- a/cmd/mesh-controller/builders_own_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package main - -import ( - "context" - "errors" - "strings" - "testing" -) - -// A12 (ADR 0453 §5, ADR 0498 §7; issue 1851): the one function every ask goes through refuses any module but the -// builder's own, naming the builder's key it is taken from instead; the builder's own module passes the refusal. -func TestBuildAgentIsAskedForTheBuildersOwnModuleAlone(t *testing.T) { - was := askedModuleOf - t.Cleanup(func() { askedModuleOf = was }) - askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) { - return strings.TrimPrefix(path, "modules/"), nil - } - _, err := askABuild(t.Context(), buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, "modules/sudo", "main") - if !errors.Is(err, errNotTheBuildersOwn) || !strings.Contains(err.Error(), "sudo is taken from the builder's done key") || - !strings.Contains(err.Error(), "mesh-delivery-builder_builds") { - t.Fatalf("an ask of another module: %v", err) - } - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, - "modules/mesh-delivery-builder"); err != nil { - t.Fatalf("the builder's own module is refused: %v", err) - } - askedModuleOf = func(context.Context, buildSource, string) (string, error) { return "", errors.New("no store") } - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/x"}, ""); !errors.Is(err, errNotTheBuildersOwn) { - t.Fatalf("an ask whose module cannot be read went on: %v", err) - } -} - -// A12: a walk of the builder's own module and one other asks build-agent for the first alone; the other is taken from -// its key. -func TestAWalkAsksBuildAgentForTheBuildersOwnModuleAlone(t *testing.T) { - var asked []string - was := askABuild - askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) { - asked = append(asked, path) - return "build-1", nil - } - t.Cleanup(func() { askABuild = was }) - p, byName := aWalkOfOne("sudo") - p.Tiers = [][]string{{"mesh-delivery-builder", "sudo"}} - byName["mesh-delivery-builder"] = byName["sudo"] - e := byName["mesh-delivery-builder"] - e.Source.Repository, e.Source.Path = "novox/mesh-delivery", "modules/mesh-delivery-builder" - byName["mesh-delivery-builder"] = e - for _, m := range p.Tiers[0] { - askModule(t.Context(), p, m, byName) - } - if len(asked) != 1 || asked[0] != "modules/mesh-delivery-builder" { - t.Fatalf("build-agent was asked for %v, want the builder's own module alone", asked) - } - if s := p.Modules["mesh-delivery-builder"]; s.Key || s.Build != "build-1" { - t.Fatalf("the builder's own module is not asked: %+v", s) - } - if s := p.Modules["sudo"]; !s.Key || s.Build != "" { - t.Fatalf("the other module is not taken from its key: %+v", s) - } -} - -// ADR 0520 §1 (issue 1851): until R4g build-agent and mesh-tools, the toolchain image its builds stand on, are build-agent's -// own, as the builder's module is: the builder writes no key of them, so a walk asks build-agent for them and reads no key; -// mesh-tools-go (its key from mesh-build-image) and any other module of the same tier are still taken from their keys. -func TestAWalkAsksBuildAgentForItselfAndMeshToolsAndReadsNoKeyOfThem(t *testing.T) { - own := []string{"build-agent", "mesh-tools"} - keyed := []string{"mesh-tools-go", "sudo"} - wasModule := askedModuleOf - askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) { - return strings.TrimPrefix(path, "modules/"), nil - } - t.Cleanup(func() { askedModuleOf = wasModule }) - for _, m := range own { - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/x", Seat: "git"}, "modules/"+m); err != nil { - t.Fatalf("an ask of %s is refused: %v", m, err) - } - } - if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/x", Seat: "git"}, "modules/mesh-tools-go"); !errors.Is(err, errNotTheBuildersOwn) { - t.Fatalf("an ask of mesh-tools-go went on: %v", err) - } - var asked []string - was := askABuild - askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) { - asked = append(asked, path) - return "build-" + strings.TrimPrefix(path, "modules/"), nil - } - t.Cleanup(func() { askABuild = was }) - p, byName := aWalkOfOne("sudo") - p.Tiers = [][]string{append(append([]string{}, own...), keyed...)} - for _, m := range append(append([]string{}, own...), keyed...) { - e := byName["sudo"] - e.Source.Path = "modules/" + m - byName[m] = e - } - for _, m := range p.Tiers[0] { - askModule(t.Context(), p, m, byName) - } - if strings.Join(asked, " ") != "modules/build-agent modules/mesh-tools" { - t.Fatalf("build-agent was asked for %v", asked) - } - for _, m := range own { - if s := p.Modules[m]; s.Key || s.Build != "build-"+m || s.State != "asked" { - t.Fatalf("%s is not asked of build-agent, or is read from a key: %+v", m, s) - } - } - for _, m := range keyed { - if s := p.Modules[m]; !s.Key || s.Build != "" { - t.Fatalf("%s is not taken from its key: %+v", m, s) - } - } -} diff --git a/cmd/mesh-controller/chain_integration_test.go b/cmd/mesh-controller/chain_integration_test.go index ea086c9c..57a72b45 100644 --- a/cmd/mesh-controller/chain_integration_test.go +++ b/cmd/mesh-controller/chain_integration_test.go @@ -14,7 +14,6 @@ import ( "github.com/novox/mesh-controller/internal/chain" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/lease" - "github.com/novox/mesh-controller/internal/link" ) // **Acceptance 10 of design 47: one read of an assignment names the commit, the build and the rollout that put it there, @@ -80,7 +79,7 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou if err != nil { t.Fatal(err) } - for try := 0; try < 3 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == ""); try++ { + for try := 0; try < 6 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == "" || asking.Modules["notes"].State == "asked"); try++ { advancePlans(ctx, open) if asking, err = inv.PlanByID(ctx, rollout); err != nil { t.Fatal(err) @@ -90,13 +89,6 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou if buildID == "" || len(*asked) != 1 || !strings.HasSuffix((*asked)[0], "#"+buildID) { t.Fatalf("the walk asked %v, and notes' build is %q", *asked, buildID) } - digest := "sha256:" + strings.Repeat("a", 64) - result := outcome(buildID, "modules/notes", "c-notes", "notes", "") - result.ReuseKey = "rk-notes" - result.Made = []link.MadeArtifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes/notes@" + digest}} - if err := (builds{inv: inv, open: open}).Built(ctx, result); err != nil { - t.Fatal(err) - } // The walk sends the tier to its first machine. for try := 0; try < 3 && len(sent) == 0; try++ { @@ -116,9 +108,9 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou if wireguard, pulled := assignmentOf(on, "mesh-wireguard"); pulled && (wireguard.Build != "" || wireguard.Rollout != "") { t.Fatalf("a module this rollout did not send names a build or a rollout: %+v", wireguard) } - if a.Module != "notes" || a.Commit != "c-notes" || a.Build != buildID || a.Rollout != rollout || a.Artifact != digest || + if a.Module != "notes" || a.Commit != "c-notes" || a.Build != buildID || a.Rollout != rollout || a.Since.IsZero() || a.Assigned.IsZero() || !a.Stands() { - t.Fatalf("the assignment names %+v, not commit c-notes, build %s, artifact %s, rollout %s", a, buildID, digest, rollout) + t.Fatalf("the assignment names %+v, not commit c-notes, build %s, rollout %s", a, buildID, rollout) } // One read of the rollout. p, err := inv.PlanByID(ctx, rollout) @@ -130,16 +122,11 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou p.Steps[0].Node != "anchor" || p.Steps[0].Sent == nil { t.Fatalf("the rollout lists changed %+v, builds %v, steps %+v; want %+v", p.Changed, p.Builds, p.Steps, wantChange) } - // And one read of the build names the same commit, with what it published and its reuse key. + // And one read of the build names the same commit. built, found, err := inv.BuildByID(ctx, buildID) if err != nil || !found || built.Commit != "c-notes" || built.Module != "notes" { t.Fatalf("the build: %+v %v %v", built, found, err) } - lately, err := inv.Builds(ctx, "notes", 1) - if err != nil || len(lately) != 1 || lately[0].ReuseKey != "rk-notes" || lately[0].Published == nil || - lately[0].Published.Artifact != digest { - t.Fatalf("what the build published: %+v %v", lately, err) - } // The verbs say the links. if lines := changedLines(p); len(lines) != 2 || !strings.Contains(lines[1], "anchor/notes") || !strings.Contains(lines[1], buildID) { diff --git a/cmd/mesh-controller/chain_no_table_integration_test.go b/cmd/mesh-controller/chain_no_table_integration_test.go index 20181348..aab6ca2d 100644 --- a/cmd/mesh-controller/chain_no_table_integration_test.go +++ b/cmd/mesh-controller/chain_no_table_integration_test.go @@ -6,8 +6,6 @@ import ( "strings" "testing" "time" - - "github.com/novox/mesh-controller/internal/link" ) // **Acceptance 12 of design 47: no table in the controller's store holds builds, rollouts or assignments** (novox/hq ADR @@ -34,7 +32,7 @@ func TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments(t *testing if err != nil { t.Fatal(err) } - for try := 0; try < 3 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == ""); try++ { + for try := 0; try < 6 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == "" || asking.Modules["notes"].State == "asked"); try++ { advancePlans(ctx, open) if asking, err = inv.PlanByID(ctx, rollout); err != nil { t.Fatal(err) @@ -44,12 +42,6 @@ func TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments(t *testing if buildID == "" || len(*asked) != 1 { t.Fatalf("the walk asked %v, and notes' build is %q", *asked, buildID) } - digest := "sha256:" + strings.Repeat("b", 64) - result := outcome(buildID, "modules/notes", "c-notes", "notes", "") - result.Made = []link.MadeArtifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes/notes@" + digest}} - if err := (builds{inv: inv, open: open}).Built(ctx, result); err != nil { - t.Fatal(err) - } // What the live path made is in the chain. if built, found, err := inv.BuildByID(ctx, buildID); err != nil || !found || built.Commit != "c-notes" { diff --git a/cmd/mesh-controller/changeplan.go b/cmd/mesh-controller/changeplan.go index f6d3461e..da60adf4 100644 --- a/cmd/mesh-controller/changeplan.go +++ b/cmd/mesh-controller/changeplan.go @@ -190,32 +190,3 @@ func summaryOf(p link.ChangePlan) string { } return strings.Join(parts, "; ") } - -// planText is a change plan as a person reads it, for the pull request's comment. -func planText(p link.ChangePlan) string { - var b strings.Builder - fmt.Fprintf(&b, "change plan of %s at %.8s into %s: %s\n", p.Repository, p.Head, p.Base, p.Summary) - for i, tier := range p.Tiers { - fmt.Fprintf(&b, " tier %d: %s\n", i, strings.Join(tier, ", ")) - } - for _, m := range p.Machines { - line := " " + m.Machine + ": " - if len(m.Receives) > 0 { - line += "receives " + strings.Join(m.Receives, ", ") - } - if len(m.Waits) > 0 { - if len(m.Receives) > 0 { - line += "; " - } - line += "waits for a person: " + strings.Join(m.Waits, ", ") - } - b.WriteString(line + "\n") - } - for _, s := range p.Steps { - b.WriteString(" - " + s + "\n") - } - if len(p.Unread) > 0 { - fmt.Fprintf(&b, " read by no module's build: %s\n", strings.Join(p.Unread, ", ")) - } - return b.String() -} diff --git a/cmd/mesh-controller/changeplan_test.go b/cmd/mesh-controller/changeplan_test.go index 8e537227..bb250865 100644 --- a/cmd/mesh-controller/changeplan_test.go +++ b/cmd/mesh-controller/changeplan_test.go @@ -72,7 +72,4 @@ func TestAChangePlanSaysWhatEachMachineReceives(t *testing.T) { if !strings.HasPrefix(p.Summary, "builds nothing") || len(p.Machines) != 0 || strings.Join(p.Unread, ",") != "merge-check.sh" { t.Errorf("a root file's plan reads %+v", p) } - if !strings.Contains(planText(p), "read by no module's build: merge-check.sh") { - t.Errorf("the plan's text does not say why nothing is built:\n%s", planText(p)) - } } diff --git a/cmd/mesh-controller/check_builds.go b/cmd/mesh-controller/check_builds.go deleted file mode 100644 index 51b1c0dd..00000000 --- a/cmd/mesh-controller/check_builds.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "os" - - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/inventory" -) - -// A pull request's check builds go when it closes (novox/hq ADR 0350 §5): the build seat keeps them in the artifact -// store's scratch namespace for the walk of its merge to take. **Let go of where the moment is heard**: the -// controller ends the walk answering a merge — after that walk took what it reuses — and the delivery's owner, which -// hears the forge say a pull request closed unmerged, asks the `forget-checks` verb; both let go through the store, -// as the controller's own collection does. The build seat's holders let go of anything a week old on their own, -// so a word lost here leaves a week's builds, never more. - -// checkBuildsIn is the scratch namespace as the controller reaches the artifact store. -func checkBuildsIn(ctx context.Context, inv *inventory.Inventory) (builder.Scratch, error) { - shelf, err := inv.Catalogue(ctx) - if err != nil { - return nil, err - } - address, err := artifactStoreAddress(ctx, inv, shelf, "") - if err != nil { - return nil, err - } - if address == "" { - return nil, errors.New("the artifact store is not on the mesh's network") - } - return builder.Registry{Address: address}, nil -} - -// forgetChecks lets go of a pull request's check builds and answers what it did. -var forgetChecks = func(ctx context.Context, inv *inventory.Inventory, pull builder.PullRef) (string, error) { - scratch, err := checkBuildsIn(ctx, inv) - if err != nil { - return "", err - } - n, err := scratch.Forget(ctx, pull) - if err != nil { - return "", fmt.Errorf("%s: %d check(s)' builds let go of, and not all: %w", pull, n, err) - } - return fmt.Sprintf("%s: %d check(s)' builds let go of", pull, n), nil -} - -// forgetChecksOfWalk lets go, on the save that ends a walk, of the check builds of every pull request whose merge -// it answers: the walk took what it reuses. Said, never fatal. -func forgetChecksOfWalk(ctx context.Context, inv *inventory.Inventory, p inventory.Plan) { - if p.WalkSaid != inventory.WalkEnded || p.Delivery == nil { - return - } - for _, pull := range pullsOfWalk(&p) { - said, err := forgetChecks(ctx, inv, builder.PullRef{Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number}) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: its check builds stay until the week's sweep: %v\n", p.ID, err) - continue - } - fmt.Printf("%s: %s\n", p.ID, said) - } -} diff --git a/cmd/mesh-controller/check_builds_integration_test.go b/cmd/mesh-controller/check_builds_integration_test.go deleted file mode 100644 index 1e21463c..00000000 --- a/cmd/mesh-controller/check_builds_integration_test.go +++ /dev/null @@ -1,91 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "reflect" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -func TestAWalkNamesThePullRequestsOfItsMergesOnce(t *testing.T) { - p := &inventory.Plan{Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{ - {Repository: "novox/shop", Number: 7}, {Repository: "http://forge.invalid/novox/web.git", Number: 3}, - {Repository: "novox/shop", Number: 7}, {Repository: "novox/shop"}}}} - if want := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}, {Owner: "novox", Repo: "web", Number: 3}}; !reflect.DeepEqual(pullsOfWalk(p), want) { - t.Fatalf("the walk names %+v", pullsOfWalk(p)) - } -} - -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 - gateEvery = 0 - - digest := "sha256:" + strings.Repeat("c", 64) - reused := func(id, reference string) link.BuildResult { - manifest := mustJSON(t, catalogue.Manifest{Module: "app", Version: "c3", Resources: []map[string]any{ - {"id": "svc", "type": "container", "name": "app", "image": reference}}}) - return link.BuildResult{ID: id, Repository: "novox/mesh-catalog", Path: "modules/app", Ref: "main", On: "anchor", - Commit: "c3", Manifest: manifest, Trunk: "main", OnTrunk: true, Branches: []string{"main"}, - Made: []link.MadeArtifact{{Name: "server", Kind: "image", Reference: reference}}, - Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}, - Reused: "reused from the check of novox/mesh-catalog#9 at head abcdefabcdef (merged into trunk 123456123456): server " + digest, - ReusedFrom: &link.ReusedFrom{Pull: link.PullRef{Owner: "novox", Repo: "mesh-catalog", Number: 9}, - Head: "abcdefabcdef", Trunk: "123456123456", Key: "r1:" + strings.Repeat("1", 64)}} - } - if _, _, err := takeIn(ctx, inv, reused(link.NewBuildID(time.Now()), "store.internal:5000/scratch/app/server@"+digest)); err == nil || - !strings.Contains(err.Error(), "scratch") { - t.Fatalf("a build in the scratch namespace was registered: %v", err) - } - id := link.NewBuildID(time.Now().Add(time.Second)) - result := reused(id, "store.internal:5000/app/server@"+digest) - if _, _, err := takeIn(ctx, inv, result); err != nil { - t.Fatal(err) - } - current, err := inv.CurrentBuilds(ctx) - if err != nil || current["app"].Commit != "c3" { - t.Fatalf("the reused build is not what app is registered at: %+v %v", current["app"], err) - } - shelf, err := inv.Catalogue(ctx) - if err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(shelf["app"].Resources) - if !strings.Contains(string(raw), "app/server@"+digest) { - t.Fatalf("app is registered as %s, not by the check's digest in its own namespace", raw) - } - - // The walk asked it: the walk keeps what its build did, and its gate judges the registered build. - asked := time.Now().UTC() - p := g.plan(t) - p.Commit = "c3" - p.Modules["app"] = &inventory.PlanModule{State: "asked", AskedAt: &asked, Build: id} - if err := inv.SavePlan(ctx, &p); err != nil { - t.Fatal(err) - } - planBuiltSaying(ctx, g.open, "app", "c3", "", asked, id, result.MadeWords()) - p = g.plan(t) - if m := p.Modules["app"]; m.State != "built" || !strings.HasPrefix(m.Made, "reused from the check of novox/mesh-catalog#9") { - t.Fatalf("the walk keeps %+v", m) - } - for i := 0; i < 6; i++ { - advancePlans(ctx, g.open) - } - p = g.plan(t) - gate := p.Modules["app"].Gate - if gate == nil || gate.To != "c3" || gate.Verdict != inventory.GatePassed { - t.Fatalf("the gate judged %+v, not the reused build", gate) - } - if sent, _, _ := inv.SentBuilds(ctx, "anchor"); sent["app"] != "c3" { - t.Fatalf("the first machine was sent %v, not the reused build", sent) - } -} diff --git a/cmd/mesh-controller/check_builds_test.go b/cmd/mesh-controller/check_builds_test.go deleted file mode 100644 index f0219db1..00000000 --- a/cmd/mesh-controller/check_builds_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "context" - "errors" - "reflect" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// One build per change (novox/hq ADR 0350), as the controller asks it and takes it in. - -// The check of a change is asked to build what a merge of it would move, in the walk's order: a module of the -// repository checked from the merge, one standing on it from its own repository as the walk builds it, and one -// that reads the repository through a context not at all. A change that moves no module builds nothing. -func TestACheckIsAskedToBuildWhatTheMergeWouldMove(t *testing.T) { - entries := []inventory.Entry{ - {Manifest: catalogue.Manifest{Module: "app"}, Source: inventory.Source{Repository: "novox/shop", Seat: "git", Path: "modules/app"}}, - {Manifest: catalogue.Manifest{Module: "web"}, Source: inventory.Source{Repository: "novox/web", Seat: "git", Ref: "main"}}, - {Manifest: catalogue.Manifest{Module: "docs"}, Source: inventory.Source{Repository: "novox/docs", Seat: "git"}}, - } - p := link.PullUpdated{Owner: "novox", Repo: "shop", Number: 7, Base: "main", Commit: "abc"} - scope := checkScope{Reach: mergeReach{Packaging: entries[2:], Plan: inventory.Plan{Tiers: [][]string{{"app", "docs"}, {"web"}}}}} - clone := func(s inventory.Source) (string, error) { return "http://forge.invalid/" + s.Repository + ".git", nil } - - got := checkBuildsOf(p, scope, entries, clone) - want := []link.CheckBuild{ - {Module: "app", Path: "modules/app", Merged: true, Tier: 0}, - {Module: "docs", Tier: 0, Skip: "its build reads novox/shop through a context, which a check cannot hand the merge"}, - {Module: "web", Repository: "http://forge.invalid/novox/web.git", Ref: "main", Tier: 1}, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("the check is asked to build\n%+v\nwant\n%+v", got, want) - } - if got := checkBuildsOf(p, checkScope{}, entries, clone); got != nil { - t.Fatalf("a change that moves no module asks for builds: %+v", got) - } -} - -// A walk's ask carries its leave to take a check's build, naming the pull requests of the merges it answers; a dry -// run never does, and neither does an ask that is not a walk's. - -// A walk ended lets go of its merges' check builds — only once it ended, after it took what it reuses. -func TestAWalkEndedLetsGoOfItsMergesCheckBuilds(t *testing.T) { - var forgot []builder.PullRef - was := forgetChecks - forgetChecks = func(_ context.Context, _ *inventory.Inventory, pull builder.PullRef) (string, error) { - forgot = append(forgot, pull) - return "", nil - } - t.Cleanup(func() { forgetChecks = was }) - p := inventory.Plan{ID: "plan-1", Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{ - {Repository: "novox/shop", Number: 7}}}} - - p.WalkSaid = inventory.WalkStarted - forgetChecksOfWalk(t.Context(), nil, p) - p.WalkSaid = "" - forgetChecksOfWalk(t.Context(), nil, p) - if len(forgot) != 0 { - t.Fatalf("a walk not ended let go of %v, which it may still take", forgot) - } - p.WalkSaid = inventory.WalkEnded - forgetChecksOfWalk(t.Context(), nil, p) - if want := []builder.PullRef{{Owner: "novox", Repo: "shop", Number: 7}}; !reflect.DeepEqual(forgot, want) { - t.Fatalf("the walk ended let go of %v", forgot) - } -} - -// A build in the scratch namespace is never registered: only the walk's copy in the module's own namespace is. -func TestABuildInTheScratchNamespaceIsNeverRegistered(t *testing.T) { - if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: "store.internal:5000/app/server@sha256:aa"}}); got != "" { - t.Fatalf("a module's own build reads as a scratch build: %s", got) - } - scratch := "store.internal:5000/scratch/app/server@sha256:aa" - if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: scratch}}); got != scratch { - t.Fatalf("a scratch build was not found: %q", got) - } - if got := inScratch([]link.MadeArtifact{{Name: "look", Reference: "http://store.internal:5000/v2/scratch/app/look/blobs/sha256:bb"}}); got == "" { - t.Fatal("a scratch archive was not found") - } -} - -// The walk says per module what it did: "reused from …" or "built: " — and a build seat from before says -// neither, which is said. -func TestAWalksBuildSaysWhetherItReusedOrBuilt(t *testing.T) { - if got := (link.BuildResult{Reused: "reused from the check of novox/shop#7"}).MadeWords(); got != "reused from the check of novox/shop#7" { - t.Fatalf("said %q", got) - } - if got := (link.BuildResult{BuiltWhy: "no check built it"}).MadeWords(); got != "built: no check built it" { - t.Fatalf("said %q", got) - } - if got := (link.BuildResult{}).MadeWords(); !strings.Contains(got, "predates ADR 0350") { - t.Fatalf("said %q", got) - } -} - -// A walk's module the check's build stood in for is registered by the digest the check built — the copy in the -// module's namespace — and the walk keeps "reused from …" for it; its first-node gate judges that registered -// 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. - -// 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) - } -} diff --git a/cmd/mesh-controller/check_here.go b/cmd/mesh-controller/check_here.go deleted file mode 100644 index 51e7c786..00000000 --- a/cmd/mesh-controller/check_here.go +++ /dev/null @@ -1,203 +0,0 @@ -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/link" -) - -// `check-here` is a pull request's merge check run on the machine at hand **exactly as the build seat -// runs it** (novox/hq issue 286): the same code (builder.Check), the same ask the controller would make of -// the seat — the gate's modules and judge by the planner's own answer over the facts snapshot, the -// repositories beside it at the refs the snapshot says the seat clones them at — in the toolchain image -// the mesh holds, as the user the build seat runs as, against a throwaway store and bus of the versions -// the mesh runs. -// -// **The build seat is the reference.** A merge-check.sh that passed on an agent's machine failed on the -// seat for three reasons that were each the agent's environment, never the change: a newer Go whose gofmt -// lays a file out differently, a sibling checkout at the agent's feature branch where the seat had the -// commit the mesh runs, and a different user. Run here, a check sees what the seat will. -// -// check-here [--tree ] [--base main] [--registry ] [--forge ] -// [--number ] [--user uid:gid] [--facts store|] [--keep] -// -// The checkout's HEAD is what is checked, and it must be committed: the seat checks a commit, never a -// working tree. -func checkHereCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("check-here", flag.ContinueOnError) - tree := set.String("tree", ".", "the change's checkout; its HEAD is checked") - base := set.String("base", "main", "the branch the change would merge into") - registry := set.String("registry", os.Getenv("MESH_REGISTRY"), "the artifact store holding the facts and the toolchains") - forge := set.String("forge", "", "where the repositories beside it are cloned from, as /.git; "+ - "the checkout's origin without its own name when not given") - number := set.Int("number", 0, "the pull request's number, when there is one") - // The build seat's service runs as root, and its check containers run as the builder does. - user := set.String("user", "0:0", "the user the check's containers run as: the build seat's") - keep := set.Bool("keep", false, "keep the workspace afterwards") - factsFrom := set.String("facts", "store", "the facts snapshot: `store`, the one the artifact store holds — "+ - "what the seat reads — or a file") - if _, err := parseAround(set, args); err != nil { - return err - } - if *registry == "" { - return errors.New("check-here reads the facts and the toolchains from the artifact store: --registry or MESH_REGISTRY") - } - dir, err := filepath.Abs(*tree) - if err != nil { - return err - } - git := func(args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - out, err := cmd.Output() - return strings.TrimSpace(string(out)), err - } - if dirty, err := git("status", "--porcelain", "--untracked-files=no"); err != nil { - return fmt.Errorf("%s is not a checkout: %w", dir, err) - } else if dirty != "" { - return errors.New("the checkout has changes not committed: the build seat checks a commit, so commit first") - } - head, err := git("rev-parse", "HEAD") - if err != nil { - return err - } - origin, err := git("remote", "get-url", "origin") - if err != nil { - return fmt.Errorf("the checkout has no origin to say which repository it is: %w", err) - } - owner, repo, prefix := ownerRepoOf(origin) - if *forge == "" { - *forge = prefix - } - if _, err := git("fetch", "--quiet", "origin", *base); err != nil { - return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err) - } - // Without rename detection, so a file moved out of a build source is said under its old name as well: - // its going is a change to the build that held it (novox/hq ADR 0267). - changedText, err := git("diff", "--name-only", "--no-renames", "origin/"+*base+"...HEAD") - if err != nil { - return err - } - var paths, removed []string - for _, p := range strings.Split(changedText, "\n") { - if p = strings.TrimSpace(p); p == "" { - continue - } - paths = append(paths, p) - if _, err := os.Stat(filepath.Join(dir, p)); os.IsNotExist(err) { - removed = append(removed, p) - } - } - - _ = os.Setenv("MESH_REGISTRY", *registry) - f, err := readFacts(ctx, *factsFrom) - if err != nil { - return fmt.Errorf("the facts snapshot cannot be read: %w", err) - } - entries, read, edges, err := graphOfFacts(f) - if err != nil { - return err - } - p := link.PullUpdated{Owner: owner, Repo: repo, Number: *number, Base: *base, Commit: head, Paths: paths, - Removed: removed, ModuleDirs: moduleDirsIn(dir, paths), ModuleDirsSaid: true} - scope := pullScope(p, entries, read, edges) - _, scriptErr := os.Stat(filepath.Join(dir, builder.CheckScript)) - if !scope.gated() && !scope.Mesh { - fmt.Printf("%s: %s, and the repository is not the mesh's: the build seat runs nothing for it\n", - owner+"/"+repo, noModuleTouched) - return nil - } - if !scope.gated() && scriptErr != nil { - fmt.Printf("%s: %s; %s\n", owner+"/"+repo, noModuleTouched, noMergeCheck) - return nil - } - - toolchains := map[string]string{} - for language, reference := range f.Versions.Toolchains { - toolchains[language] = catalogue.Rerouted(reference, *registry) - } - if len(toolchains) == 0 { - return errors.New("the facts snapshot names no toolchain: it was taken by a controller from before " + - "issue 286, and the seat's toolchain cannot be known here") - } - beside := map[string]builder.Beside{} - for d, ref := range f.Beside { - from := d - if d == "mesh-controller-main" { - from = "mesh-controller" - } - beside[d] = builder.Beside{Repository: strings.TrimSuffix(*forge, "/") + "/" + from + ".git", Ref: ref} - } - id := fmt.Sprintf("check-here-%d", time.Now().UnixNano()) - spec := builder.CheckSpec{ID: id, Repository: dir, Ref: head, Owner: owner, Repo: repo, Number: *number, - Paths: paths, Beside: beside, Modules: scope.Modules, New: scope.New, Manifests: scope.Manifests, - Base: *base, Judge: scope.Judge, Toolchain: toolchains["go"], Toolchains: toolchains, User: *user} - - workspace, err := os.MkdirTemp("", "mesh-check-here-") - if err != nil { - return err - } - if !*keep { - defer removeWorkspace(spec.Toolchain, workspace, *user) - } - fmt.Fprintf(os.Stderr, "checking %s/%s at %.8s as the build seat would, against the facts of %s, in %s\n", - owner, repo, head, f.Taken.Format(time.RFC3339), workspace) - v, err := builder.Check(ctx, builder.Command, spec, workspace, *registry, builder.GitCredential{}, - func(step, message string) { - if step != "output" { - fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) - } - }) - if err != nil { - return fmt.Errorf("the check could not run — on the seat an error, never a pass: %w", err) - } - fmt.Println(v.Report) - fmt.Println() - fmt.Printf("mesh/merge-gate: %s — %s\n", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary) - if v.Repo != nil { - fmt.Printf("mesh/repo-check: %s — %s\n", strings.ToUpper(v.Repo.Verdict), v.Repo.Summary) - } - for _, l := range []*builder.Layer{v.Gate, v.Repo} { - if l != nil && l.Verdict != "pass" && l.Verdict != "warning" { - return errors.New("the build seat would not pass this change") - } - } - return nil -} - -// ownerRepoOf reads owner, repository and the owner's URL from a remote: ssh://git@host:222/novox/mesh-host.git -// → novox, mesh-host, ssh://git@host:222/novox. -func ownerRepoOf(remote string) (string, string, string) { - trimmed := strings.TrimSuffix(strings.TrimSuffix(remote, "/"), ".git") - cut := strings.LastIndexAny(trimmed, "/:") - if cut < 0 { - return "", trimmed, "" - } - repo, prefix := trimmed[cut+1:], trimmed[:cut] - owner := prefix - if at := strings.LastIndexAny(prefix, "/:"); at >= 0 { - owner = prefix[at+1:] - } - return owner, repo, prefix -} - -// removeWorkspace removes what the check left, written as the seat's user: by a container of that user -// when it is not this one. -func removeWorkspace(image, workspace, user string) { - if image != "" && user != fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) { - // Everything in it — the check's HOME is the workspace, so the toolchain's own files are there too. - _ = exec.Command("docker", "run", "--rm", "--user", user, "--volume", workspace+":/workspace", image, - "sh", "-c", "rm -rf /workspace/* /workspace/.[!.]*").Run() - } - _ = os.RemoveAll(workspace) -} diff --git a/cmd/mesh-controller/check_pulls_test.go b/cmd/mesh-controller/check_pulls_test.go deleted file mode 100644 index c3baa27d..00000000 --- a/cmd/mesh-controller/check_pulls_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "reflect" - "testing" - - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// 1212/AC1 and AC2: the check request names the pulls bucket of each module claiming the delivery seat, in the order -// of the claimants' names. -func TestACheckNamesThePullsBucketsOfTheSeatsClaimants(t *testing.T) { - for name, c := range map[string]struct { - entries []inventory.Entry - want []string - }{ - "only the gate": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), namedOnly("mesh-delivery")}, []string{"mesh-delivery-gate_pulls"}}, - "only today's": {[]inventory.Entry{claimingTheSeat("mesh-delivery"), namedOnly("gitea")}, []string{"mesh-delivery_pulls"}}, - "both, name order": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls", "mesh-delivery-gate_pulls"}}, - "none": {[]inventory.Entry{namedOnly("gitea")}, nil}, - } { - if got := pullsBucketsOf(c.entries); !reflect.DeepEqual(got, c.want) { - t.Errorf("%s: %v, want %v", name, got, c.want) - } - } -} - -// 1212/AC1 and AC2: the check a request carries names those buckets (the wiring, not only the helper). -func TestTheCheckRequestCarriesThePullsBuckets(t *testing.T) { - for name, c := range map[string]struct { - entries []inventory.Entry - want []string - }{ - "only the gate": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate")}, []string{"mesh-delivery-gate_pulls"}}, - "only today's": {[]inventory.Entry{claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls"}}, - "both, name order": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls", "mesh-delivery-gate_pulls"}}, - } { - got := checkOf(link.PullUpdated{Owner: "novox", Repo: "a", Number: 7}, checkScope{}, c.entries, nil, nil, nil, nil) - if !reflect.DeepEqual(got.Pulls, c.want) || got.Owner != "novox" || got.Number != 7 { - t.Errorf("%s: %+v, want pulls %v", name, got, c.want) - } - } -} diff --git a/cmd/mesh-controller/checks.go b/cmd/mesh-controller/checks.go index bbd6edc3..f3d84c34 100644 --- a/cmd/mesh-controller/checks.go +++ b/cmd/mesh-controller/checks.go @@ -2,9 +2,6 @@ package main import ( "context" - "encoding/json" - "fmt" - sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "io" "os" "path" @@ -13,46 +10,18 @@ import ( "slices" "sort" "strings" - "time" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) -// A pull request's merge check (novox/hq to-be 45 §9, ADR 0237 as amended 2026-10-06): the forge -// announces every pull request's new head, the controller decides what is checked, asks the build seat to -// check it, and says the verdict as `checked`, which the forge's holder sets as the pull request's -// statuses. **Before merge, never after**: every check the mesh had ran after a merge, on a machine. -// -// **The mesh's module graph decides, not the repository.** The controller holds the graph — every module, -// the repository and directory it is built from — and maps the pull request's changed paths onto it by -// the planner's own answer (reachOfMerge, touchedBy — the one place a changed file is mapped onto modules, -// for the merge handler, the release planner, the merge gate and this check; issue 280): **a changed file -// touches exactly the modules whose build reads it** — a module's own directory (the whole repository for -// one built from its root), or a repository its recipe packages. A file no build reads — a script at the -// root, a README — touches no module. A directory the change adds a module.json in, which the graph does -// not hold yet (said by the head, issue 278), is a new module and is checked too. -// -// - touches a module: the build seat runs **the gate** — `mesh/merge-gate`, the touched manifests, every -// machine composed with the change, the replays — and the repository's own merge-check.sh beside it; -// - touches none, in a repository that is the mesh's (it sources a module on some branch, or shares the -// core's owner): the gate is a pass that says so — a fact, not a missing check — and the repository's -// own merge-check.sh runs as `mesh/repo-check`, a warning when it has none; -// - touches none, anywhere else: the gate is a pass that says so, and nothing more is said. -// -// What is checked is decided here and run there (internal/builder/check.go). - -// checkTimeout is how long one check may run on the build seat. Said here so the ask's watchdog (S6) -// and the builder agree on what late means. -const checkTimeout = 45 * time.Minute - -// noModuleTouched is the gate's word for a change that touches nothing of the graph. -const noModuleTouched = "the change touches no module of the mesh's graph" - -// noMergeCheck is the repository layer's word for a repository of the mesh with no merge-check.sh. -const noMergeCheck = "the repository declares no merge-check.sh: none of its own tests run before it merges" +// What a pull request reaches of the mesh's graph (novox/hq to-be 45 §9, issue 280). The controller no longer checks a +// pull request or says a verdict on it (R4g, ADR 0503 §3, ADR 0501 §5): the checker judges the heads, and mesh-delivery +// asks the operator's Approve on its verdict. What stays here is the mapping of a pull request's changed paths onto the +// mesh's module graph by the planner's own answer (reachOfMerge, touchedBy), which the delivery verbs (`delivery order`, +// the change plan) and the head's declared config (tellPullDeclares) read: **a changed file touches exactly the modules +// whose build reads it** — a module's own directory (the whole repository for one built from its root), or a repository +// its recipe packages — and a directory the change adds a module.json in is a new module. // coreModules are the modules whose repositories are the mesh's core, by the directory a check finds // each beside it — and whose owner is the mesh's own. @@ -76,8 +45,6 @@ type checkScope struct { // Mesh says the repository is the mesh's: modules are built from it on some branch, its owner is the // core's, or the change adds a module to it. Mesh bool - // Judge is who judges the gate (link.JudgeSelf, or the running controller). - Judge string // From is a module built from the repository, for how the mesh clones it; nil when none is. From *inventory.Entry // Reach is the planner's whole answer, which the change plan is made from. @@ -99,10 +66,6 @@ func pullScope(p link.PullUpdated, entries []inventory.Entry, read map[string][] Width: len(r.Plan.Modules), Tiers: len(r.Plan.Tiers), Reach: r} for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) { s.Manifests = append(s.Manifests, path.Join(strings.Trim(e.Source.Path, "/"), moduleManifestFile)) - switch e.Manifest.Module { - case "mesh-controller": - s.Judge = link.JudgeSelf - } } for _, d := range r.Added { s.Manifests = append(s.Manifests, path.Join(d, moduleManifestFile)) @@ -138,35 +101,29 @@ func sourceOwner(repository string) string { return strings.ToLower(parts[len(parts)-2]) } -// PullUpdated decides a pull request's merge check, and asks for it when there is something to run. +// PullUpdated hears a pull request's new head. It asks no check and says no verdict (pullUpdated). func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error { - _, err := f.pullUpdated(ctx, p, os.Stdout) - return err + return f.pullUpdated(ctx, p, os.Stdout) } -// pullUpdated is PullUpdated saying what it did to w. It returns the id of the check it asked of the build seat, or -// "" when it said a verdict itself and asked nothing. A command that answers JSON on standard output (`delivery -// check`) gives it standard error, so its output stays the document alone (novox/hq issue 1311). -func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Writer) (string, error) { +// pullUpdated is PullUpdated saying what it did to w. It asks nothing of anybody and says no verdict (R4g; ADR 0503 §3, §5, +// ADR 0527): the checker judges every repository whose trunk requires `mesh/check`, hq included. All it still does is tell +// mesh-config what the head declares (ADR 0502), whatever judges it. +func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Writer) error { inv := f.open.inventory entries, err := inv.Catalogued(ctx) if err != nil { - return "", err + return err } read, err := readForPlanning(ctx, inv) if err != nil { - return "", err + return err } edges, err := inv.Dependencies(ctx) if err != nil { - return "", err + return err } scope := pullScope(p, entries, read, edges) - // **The merge gate's checks stop** (novox/hq ADR 0503 §3, §5; issue 1299): the checker judges every repository whose - // trunk requires `mesh/check`. A repository whose trunk requires `mesh/repo-check` (hq), whatever else it requires - // (ADR 0513 §4), has its own merge-check.sh asked of build-agent, and said with that layer alone; any other is asked - // nothing and said nothing. What the forge's protection requires is read, never listed (lead decision L11). - // Mesh-config is still told what the head declares (ADR 0502), whatever judges it. this := pullAtOf(p) if this.Truncated { this.Paths, this.Truncated = truncatedPaths(entries, p.Owner, p.Repo, scope.Modules), false @@ -174,310 +131,7 @@ func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Wri if scope.gated() || holdsModules(entries, p.Owner, p.Repo) { tellPullDeclares(ctx, w, []pullAt{this}) } - required, err := requiredStatusesOf(ctx, p.Owner, p.Repo, p.Base) - switch { - case err != nil: - fmt.Fprintf(w, "%s/%s#%d (%.8s): what %s's protection requires cannot be read, so nothing is asked and nothing is "+ - "said: %v\n", p.Owner, p.Repo, p.Number, p.Commit, p.Base, err) - return "", nil - case !asksRepoCheck(required): - fmt.Fprintf(w, "%s/%s#%d (%.8s): %s does not require %s, and %s; nothing is asked\n", p.Owner, p.Repo, p.Number, - p.Commit, p.Base, sdkdelivery.StatusContextRepoCheck, checkerWords(required)) - return "", nil - } - return f.repoCheckAlone(ctx, w, p, scope, entries) -} - -// asksRepoCheck says what a trunk's protection has the controller ask: its repository's own merge-check.sh whenever the -// trunk requires `mesh/repo-check`, whatever else it requires (ADR 0513 §4: a trunk requiring both has the checker judge -// `mesh/check` and the repository check asked beside it), and nothing otherwise. -func asksRepoCheck(required []string) bool { - return slices.Contains(required, sdkdelivery.StatusContextRepoCheck) -} - -// checkerWords says who judges a trunk that requires no `mesh/repo-check`. -func checkerWords(required []string) string { - if slices.Contains(required, sdkdelivery.StatusContextCheck) { - return "the checker judges its " + sdkdelivery.StatusContextCheck - } - return "does not require " + sdkdelivery.StatusContextCheck + " either, so nothing is said" -} - -// repoCheckAlone asks build-agent for a repository's own merge-check.sh alone, for a pull request of a repository whose -// trunk requires `mesh/repo-check`, whether or not it also requires `mesh/check` (ADR 0503 §5, ADR 0513 §4): no gate, no module built, its verdict said with the -// repo-check layer alone. -func (f following) repoCheckAlone(ctx context.Context, w io.Writer, p link.PullUpdated, scope checkScope, - entries []inventory.Entry) (string, error) { - inv := f.open.inventory - plan := changePlanOf(p.Owner+"/"+p.Repo, p.Base, p.Commit, scope.Reach, entries, func(module string) (inventory.Upgrade, bool) { - u, err := inv.UpgradeOf(ctx, module) - return u, err == nil - }) - fmt.Fprint(w, planText(plan)) - direct := directVerdict(p, plan) - switch { - case !scope.Mesh: - direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: "the repository is not the mesh's: nothing run"} - fmt.Fprintf(w, "%s/%s#%d (%.8s): the repository is not the mesh's: said, nothing run\n", p.Owner, p.Repo, p.Number, - p.Commit) - sayChecked(ctx, w, direct) - return "", nil - case p.MergeCheckSaid && !p.MergeCheck: - direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: noMergeCheck} - fmt.Fprintf(w, "%s/%s#%d (%.8s): %s\n", p.Owner, p.Repo, p.Number, p.Commit, noMergeCheck) - sayChecked(ctx, w, direct) - return "", nil - } - request, err := checkRequestFor(ctx, f.open, p, checkScope{Mesh: true, From: scope.From}, entries) - if err != nil { - return "", err - } - request.Check.Plan = &plan - seat := buildSeatHeld(ctx) - ask, err := askOverOn(seat) - if err != nil { - return "", err - } - defer ask.Close() - if err := ask.Ask(ctx, request); err != nil { - return "", err - } - fmt.Fprintf(w, "%s/%s#%d (%.8s): asked %s to run its own merge-check.sh alone, as %s\n", p.Owner, p.Repo, p.Number, - p.Commit, seat, request.ID) - return request.ID, nil -} - -// requiredStatusesOf is what the forge's protection of a repository's branch requires before a merge, as the forge -// picks the rule it applies (the one named for the branch, else the first glob covering it). A variable so a test needs -// no forge. -var requiredStatusesOf = func(ctx context.Context, owner, repo, branch string) ([]string, error) { - var rules struct { - Rules []protectionRule `json:"rules"` - } - js, err := aBus() - if err != nil { - return nil, err - } - defer js.Close() - raw, _ := json.Marshal(map[string]any{"owner": owner, "repo": repo}) - answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_branch_protection_get", raw, 30*time.Second) - if err != nil { - return nil, err - } - if answer.Error != "" { - return nil, fmt.Errorf("gitea.gitea_branch_protection_get: %s", answer.Error) - } - if err := json.Unmarshal(answer.Result, &rules); err != nil { - return nil, err - } - r := appliedRule(rules.Rules, branch) - if r == nil { - // empty-on-error: not an error; no rule covers the branch, so it requires nothing, which the caller says - return nil, nil - } - return r.RequiredStatuses, nil -} - -// composedWords is what a group member's own verdict says of its gate layer (novox/hq ADR 0403 clause 1). -const composedWords = "judged by its group's composed check" - -// gateComposed is a gate layer's verdict when no gate was run for the member alone: never a pass. Mesh-delivery reads it -// as the composed check's to judge, and the forge's holder sets no mesh/merge-gate from it. -const gateComposed = "composed" - -// composedGate says a group member's own verdict has no gate of its own: its gate layer, and the verdict that is -// the gate's, read "composed" in place of a pass. -func composedGate(c *link.Checked) { - c.Verdict, c.Summary = gateComposed, composedWords - c.Gate = &link.CheckLayer{Verdict: gateComposed, Summary: composedWords} -} - -// directVerdict is the verdict of a change no build judges — it touches no module, and either the repository is not -// the mesh's or the head holds no merge-check.sh. **It names the trunk commit it holds for** (novox/hq ADR 0323 §3): -// the base branch's head the forge announced with the head, so the merge tool can weigh it against the trunk's head -// and does not refuse it for ever (a verdict naming no trunk is never the trunk's head's). Nothing was built to -// judge: the trunk commit is the one the forge said the change was announced against. -func directVerdict(p link.PullUpdated, plan link.ChangePlan) link.Checked { - trunk := p.BaseSHA - if len(trunk) > 12 { - trunk = trunk[:12] - } - if trunk != "" { - plan.Trunk = trunk - } - return link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit, - ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched, - Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}, Plan: &plan, Trunk: trunk} -} - -// checkRequestFor is the ask for one pull request's head: the repository as the mesh clones it, the head, -// and what is read beside it. -func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scope checkScope, - entries []inventory.Entry) (link.BuildRequest, error) { - shelf := map[string]catalogue.Manifest{} - for _, e := range entries { - shelf[e.Manifest.Module] = e.Manifest - } - world, err := restOfTheMesh(ctx, open, shelf, "") - if err != nil { - return link.BuildRequest{}, err - } - clone := func(s inventory.Source) (string, error) { - if s.Seat == "" { - return s.Repository, nil - } - return clonedFromSeat(world, s.Seat, s.Repository) - } - // As the mesh clones a module built from it; a repository no module is built from, from the forge. - source := inventory.Source{Seat: gitSeat, Repository: p.Owner + "/" + p.Repo} - if scope.From != nil { - source = scope.From.Source - } - repository, err := clone(source) - if err != nil { - return link.BuildRequest{}, err - } - current, err := open.inventory.CurrentBuilds(ctx) - if err != nil { - return link.BuildRequest{}, err - } - beside, err := besideOf(entries, func(module string) string { return current[module].Commit }, clone) - if err != nil { - return link.BuildRequest{}, err - } - builds := checkBuildsOf(p, scope, entries, clone) - return link.BuildRequest{ - ID: link.NewBuildID(time.Now()), - Repository: repository, - Ref: p.Commit, - Held: heldBy(ctx), - Seats: seatBases(ctx), - Source: sourceOnSeat(source), - Check: checkOf(p, scope, entries, beside, builds, namedForTheCheck(ctx, p, builds), runningBy(ctx)), - }, nil -} - -// checkOf is the check a request carries: the pull request, what the change touches, and the buckets of the pull -// requests' keys of the modules claiming the delivery seat (novox/hq ADR 0437 §3, issue 1212). -func checkOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry, beside map[string]link.CheckedOut, - builds []link.CheckBuild, named []link.CheckedBuild, running map[string]string) *link.CheckRequest { - return &link.CheckRequest{Pulls: pullsBucketsOf(entries), Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base, - Trunk: p.BaseSHA, Ready: p.Ready, Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, - New: scope.New, Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: named, Running: running} -} - -// 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. 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)) (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)) - beside[dir] = link.CheckedOut{Repository: url, Ref: refs[dir]} - 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 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]} - } - } - } - } - } - return beside, 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" -} - -// 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 -// branch it follows, as the walk builds it. A module whose build reads the repository checked through a context, -// or that the merge deletes, cannot be built as the walk would: said, and nothing after it is built. A change that -// moves no module builds nothing. -func checkBuildsOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry, - clone func(inventory.Source) (string, error)) []link.CheckBuild { - if len(scope.Reach.Plan.Tiers) == 0 { - return nil - } - m := link.SourceMoved{Owner: p.Owner, Repo: p.Repo, Base: p.Base, CloneURL: p.CloneURL} - byName := map[string]inventory.Entry{} - for _, e := range entries { - byName[e.Manifest.Module] = e - } - skip := map[string]string{} - for _, e := range scope.Reach.Packaging { - skip[e.Manifest.Module] = "its build reads " + p.Owner + "/" + p.Repo + " through a context, which a check " + - "cannot hand the merge" - } - for _, e := range scope.Reach.Deleted { - skip[e.Manifest.Module] = "the merge deletes it" - } - var out []link.CheckBuild - for tier, names := range scope.Reach.Plan.Tiers { - for _, name := range names { - e, known := byName[name] - b := link.CheckBuild{Module: name, Tier: tier, Skip: skip[name]} - switch { - case !known: - b.Skip = "the catalogue does not hold it" - case b.Skip != "": - case sourceIs(e.Source, m): - b.Merged, b.Path = true, e.Source.Path - default: - url, err := clone(e.Source) - if err != nil { - b.Skip = "its repository cannot be cloned: " + err.Error() - break - } - b.Repository, b.Path, b.Ref = url, e.Source.Path, followedBranch(e.Source.Ref) - } - out = append(out, b) - } - } - return out + return nil } // besideRefs is the ref each repository is cloned at beside a check, by the directory it is found under: @@ -533,201 +187,5 @@ func sdkPinned() string { return "main" } -// siblingOf is another repository of the same owner: novox/mesh-controller → novox/mesh-lab. -func siblingOf(repository, name string) string { - if cut := strings.LastIndex(repository, "/"); cut >= 0 { - return repository[:cut+1] + name - } - return name -} - -// sourceOnSeat is a source's seat form, nil for one on no seat. -func sourceOnSeat(s inventory.Source) *link.SourceOnSeat { - if s.Seat == "" { - return nil - } - return &link.SourceOnSeat{Seat: s.Seat, Repository: s.Repository} -} - // checkEvents is where the serving controller says a check's verdict; nil in a command. var checkEvents link.Bus - -// maxCheckReport is how much of a check's report travels in its verdict: enough for the failures and -// the machines, never a log. -const maxCheckReport = 60 << 10 - -// checked says a merge check's verdict as the controller's `checked`. Nothing is recorded or -// registered: a check builds nothing (issue 240's rule for a dry run, kept for a check). -func checked(ctx context.Context, result link.BuildResult) { - // **A check stopped by its key says nothing** (novox/hq ADR 0406 §4, issue 974, review 2): it stopped because the head or - // the trunk commit moved on, and the newer request will judge. Said as an error it would name the live head — red - // statuses on the forge, a delivery refused — and written to the key it would stand in the newer request's way. - if result.Failed == link.SupersededByKey { - fmt.Printf("%s: stopped, its pull request's key moved on; the newer check will judge\n", result.ID) - return - } - sayChecked(ctx, os.Stdout, checkedOf(result)) -} - -// checkedOf is what a check's outcome says: each layer, and an error — never a pass — for a check that -// could not run. -func checkedOf(result link.BuildResult) link.Checked { - c := link.Checked{ID: result.ID, On: result.On, Commit: result.Ref} - if result.Checked != nil { - c.Owner, c.Repo, c.Number = result.Checked.Owner, result.Checked.Repo, result.Checked.Number - } - switch { - case result.Check != nil: - c.Verdict, c.Summary, c.Report = result.Check.Verdict, result.Check.Summary, result.Check.Report - c.Gate, c.RepoCheck = result.Check.Gate, result.Check.RepoCheck - // What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the - // 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.Validator = result.Check.Validator - c.Siblings, c.SiblingsUnknown, c.JudgeFrom = result.Check.Siblings, result.Check.SiblingsUnknown, result.Check.JudgeFrom - 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} - } - case result.Failed != "": - // The check could not run: an error, never read as a pass — on both layers it was asked for. - c.Verdict, c.Summary = "error", "the check could not run: "+firstLine(result.Failed) - default: - c.Verdict, c.Summary = "error", "the build seat answered the check with no verdict" - } - if c.Verdict == "" { - c.Verdict = "error" - } - if result.Check == nil { - c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary} - c.RepoCheck = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary} - } - // **A group member's own check has no gate of its own** (novox/hq ADR 0403 clause 1): what ran was the repository's - // check alone, and its gate layer is "composed", never a pass. A failure or an error stays what it was. - if result.Checked != nil && result.Checked.Composed && result.Check != nil && c.Gate != nil && - (c.Gate.Verdict == "pass" || c.Gate.Verdict == "warning") { - composedGate(&c) - } - if result.Checked != nil && c.Gate != nil && len(c.Gate.Modules) == 0 { - c.Gate.Modules = append(append([]string{}, result.Checked.Modules...), prefixedAll("new:", result.Checked.New)...) - } - if result.Checked != nil && c.Gate != nil && len(c.Gate.Dependents) == 0 { - c.Gate.Dependents = result.Checked.Dependents - } - if result.Checked != nil { - c.Plan = result.Checked.Plan - if c.Plan != nil && c.Trunk != "" { - // The kept delivery plan records the trunk commit with its other inputs (ADR 0323 §3). - plan := *c.Plan - plan.Trunk = c.Trunk - c.Plan = &plan - } - // A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first. - if g := result.Checked; g.Group != "" { - c.Group = g.Group - // Every member is named with its trunk commit (novox/hq issue 1106): the seat's word, else the one the forge - // announced with the head, so the forge's merge tool never meets a member whose verdict names none. - c.Members = append(c.Members, link.CheckedMember{Owner: g.Owner, Repo: g.Repo, Number: g.Number, - Commit: result.Ref, Trunk: firstTrunk(c.Trunk, g.Trunk)}) - for _, m := range g.Members { - c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number, - Commit: m.Ref, Trunk: firstTrunk(memberTrunk(result.Check, m), m.Trunk)}) - } - } - } - for _, l := range []*link.CheckLayer{c.Gate, c.RepoCheck} { - if l != nil && l.Verdict == "" { - l.Verdict = "error" - } - } - // **A check run again says so** (novox/hq issue 594). The summaries are the lines the pull request - // shows, clipped by the forge to a few words, so they get only a short marker after the verdict's own - // words; the report gets the whole sentence. Layers copied, so the build's own outcome is left as it came. - if mark := link.RunMarker(result.Delivery); mark != "" { - c.Summary += " " + mark - for _, l := range []**link.CheckLayer{&c.Gate, &c.RepoCheck} { - if *l != nil { - layer := **l - layer.Summary += " " + mark - *l = &layer - } - } - c.Report = link.Restarted(result.Delivery) + "\n\n" + c.Report - } - if len(c.Report) > maxCheckReport { - c.Report = "…" + c.Report[len(c.Report)-maxCheckReport:] - } - return c -} - -// firstTrunk is the first trunk commit named, as twelve characters; empty when none is. -func firstTrunk(named ...string) string { - for _, n := range named { - if n != "" { - return twelve(n) - } - } - return "" -} - -// memberTrunk is the trunk commit the build seat merged one of a group's heads into (novox/hq ADR 0323 §6), empty -// when it said none. -func memberTrunk(outcome *link.CheckOutcome, m link.GroupMember) string { - if outcome == nil { - return "" - } - for _, j := range outcome.Members { - if strings.EqualFold(j.Owner, m.Owner) && strings.EqualFold(j.Repo, m.Repo) && strings.HasPrefix(m.Ref, j.Commit) { - return j.Trunk - } - } - return "" -} - -func prefixedAll(prefix string, items []string) []string { - out := make([]string, 0, len(items)) - for _, i := range items { - out = append(out, prefix+i) - } - return out -} - -// sayChecked says a merge check's verdict on the bus, where the forge's holder hears it. -func sayChecked(ctx context.Context, w io.Writer, c link.Checked) { - // **No merge-gate layer from R4e, for any repository** (ADR 0503 §3, §5; issue 1299): the forge's holder writes - // `mesh/repo-check` from the repo-check layer alone, and the checker owes `mesh/check`. - c.Gate = nil - repo := "none" - if c.RepoCheck != nil { - repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary - } - judged := "" - if c.Trunk != "" { - judged = " merged into trunk " + c.Trunk - } - fmt.Fprintf(w, "%s: %s/%s#%d at %.8s%s checked on %s: repository %s\n", c.ID, c.Owner, c.Repo, c.Number, - c.Commit, judged, orSomewhere(c.On), repo) - if checkEvents == nil { - return - } - body, err := json.Marshal(c) - if err != nil { - return - } - stating, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) - defer stop() - if err := checkEvents.PublishSeatEvent(stating, link.MeshControllerSeat, link.KeyChecked, body); err != nil { - fmt.Fprintf(w, "%s: the verdict could not be said, so the pull request is not told it: %v\n", c.ID, err) - } -} - -// pullsBucketsOf are the buckets of the pull requests' keys a check names for the build agent: one per module -// claiming the delivery seat, in the order of their names (novox/hq ADR 0437 §3, issue 1212). -func pullsBucketsOf(entries []inventory.Entry) []string { - var out []string - for _, m := range claimantsOf(entries, catalogue.DeliverySeat) { - out = append(out, broker.BucketName(m, "pulls")) - } - return out -} diff --git a/cmd/mesh-controller/checks_integration_test.go b/cmd/mesh-controller/checks_integration_test.go deleted file mode 100644 index 447c4e2e..00000000 --- a/cmd/mesh-controller/checks_integration_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "testing" - "time" - - "github.com/nats-io/nats.go" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/link" - "github.com/novox/mesh-controller/internal/testbus" -) - -func TestACheckStoppedByItsKeyIsAnnouncedAsNoVerdict(t *testing.T) { - js, err := broker.Dial(testbus.URL(t)) - if err != nil { - t.Fatal(err) - } - t.Cleanup(js.Close) - if err := broker.AssertMeshStreams(js); err != nil { - t.Fatal(err) - } - legacy, err := js.Conn().JetStream() - if err != nil { - t.Fatal(err) - } - was := checkEvents - checkEvents = link.OverNATS{Conn: js.Conn(), JS: legacy} - t.Cleanup(func() { checkEvents = was }) - heard := make(chan *nats.Msg, 4) - sub, err := js.Conn().ChanSubscribe(link.SeatEventSubject(link.MeshControllerSeat, link.KeyChecked), heard) - if err != nil { - t.Fatal(err) - } - defer func() { _ = sub.Unsubscribe() }() - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 3} - checked(t.Context(), link.BuildResult{ID: "stopped", Ref: "abc", Checked: asked, Failed: link.SupersededByKey}) - checked(t.Context(), link.BuildResult{ID: "broken", Ref: "abc", Checked: asked, Failed: "the facts snapshot cannot be read"}) - select { - case m := <-heard: - var c link.Checked - _ = json.Unmarshal(m.Data, &c) - if c.ID != "broken" || c.Verdict != "error" { - t.Fatalf("the first verdict said is %+v; want only the broken check's error", c) - } - case <-time.After(5 * time.Second): - t.Fatal("nothing was said") - } - select { - case m := <-heard: - t.Fatalf("a second verdict was said: %s", m.Data) - case <-time.After(300 * time.Millisecond): - } -} diff --git a/cmd/mesh-controller/checks_stop_integration_test.go b/cmd/mesh-controller/checks_stop_integration_test.go deleted file mode 100644 index d0496f3a..00000000 --- a/cmd/mesh-controller/checks_stop_integration_test.go +++ /dev/null @@ -1,109 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// C1 (ADR 0503 §3, §5; issue 1299, lead decision L11), with the store and a real build queue: a pull request of a -// repository whose trunk requires mesh/check asks build-agent nothing and says nothing; one whose trunk requires -// mesh/repo-check alone (hq) has its own merge-check.sh asked, and every verdict said names no gate layer; a protection -// that cannot be read asks and says nothing. -func TestThePullRequestChecksAreAskedOnlyForARepositoryTheCheckerDoesNotJudge(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - js, _ := aBuildQueue(t) - servingBus.Store(js) - t.Cleanup(func() { servingBus.Store(nil) }) - said := &saidOnTheBus{} - checkEvents = said - t.Cleanup(func() { checkEvents = nil }) - if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: "1"}, - inventory.Source{Repository: "novox/mesh-controller", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { - t.Fatal(err) - } - // hq is cloned as a module built from it is, from its URL: no forge is held in this test. - if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "hq-docs", Version: "1"}, - inventory.Source{Repository: "http://forge.invalid/novox/hq.git", Ref: "main", Path: "docs", BuiltFrom: "c0", - Head: "c0"}); err != nil { - t.Fatal(err) - } - required := map[string][]string{"novox/mesh-controller": {"mesh/check"}, "novox/hq": {"mesh/repo-check"}} - was := requiredStatusesOf - requiredStatusesOf = func(_ context.Context, owner, repo, _ string) ([]string, error) { - if r, ok := required[owner+"/"+repo]; ok { - return r, nil - } - return nil, errors.New("the forge did not answer") - } - t.Cleanup(func() { requiredStatusesOf = was }) - queued := func() int { - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - return len(q.Asks) - } - pull := func(repo string, number int, mergeCheck bool) link.PullUpdated { - p := link.PullUpdated{Owner: "novox", Repo: repo, Number: number, Base: "main", Commit: "abcdefabcdefabcdefabcdefabcdefabcdefabcd", - BaseSHA: "1111111111111111111111111111111111111111", Paths: []string{"main.go"}, ModuleDirsSaid: true} - p.MergeCheck, p.MergeCheckSaid = mergeCheck, true - return p - } - f := following{open: open, oneShot: true} - - if err := f.PullUpdated(ctx, pull("mesh-controller", 7, true)); err != nil { - t.Fatal(err) - } - if n := queued(); n != 0 || len(said.events) != 0 { - t.Fatalf("0503 §3: a repository the checker judges asked %d check(s) and said %v", n, said.events) - } - if err := f.PullUpdated(ctx, pull("unread", 3, true)); err != nil { - t.Fatal(err) - } - if n := queued(); n != 0 || len(said.events) != 0 { - t.Fatalf("L11: a protection that cannot be read asked %d check(s) and said %v", n, said.events) - } - if err := f.PullUpdated(ctx, pull("hq", 9, true)); err != nil { - t.Fatal(err) - } - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - if len(q.Asks) != 1 || q.Asks[0].Request.Check == nil || len(q.Asks[0].Request.Check.Modules) != 0 || - q.Asks[0].Request.Check.Repo != "hq" { - t.Fatalf("0503 §5: hq's repository check is not the one ask: %+v", q.Asks) - } - // A head with no merge-check.sh is said at once, with the repo-check layer alone. - if err := f.PullUpdated(ctx, pull("hq", 10, false)); err != nil { - t.Fatal(err) - } - if len(said.events) != 1 { - t.Fatalf("hq's head with no merge-check.sh was said %d time(s)", len(said.events)) - } - var c link.Checked - if err := json.Unmarshal(said.bodies[0], &c); err != nil { - t.Fatal(err) - } - if c.Gate != nil || c.RepoCheck == nil || c.RepoCheck.Verdict != "warning" { - t.Fatalf("0503 §5: the verdict said names a gate layer, or no repo-check layer: %+v", c) - } - // A trunk requiring both (hq, ADR 0513 §4): the checker judges mesh/check, and the repository check is still asked. - required["novox/hq"] = []string{"mesh/check", "mesh/repo-check"} - if err := f.PullUpdated(ctx, pull("hq", 11, true)); err != nil { - t.Fatal(err) - } - if q, err := link.ReadQueue(ctx, js, link.TheBuildMachine); err != nil || len(q.Asks) != 2 || - q.Asks[1].Request.Check == nil || q.Asks[1].Request.Check.Repo != "hq" { - t.Fatalf("0513 §4: a trunk requiring both had no repository check asked: %+v %v", q.Asks, err) - } -} diff --git a/cmd/mesh-controller/checks_test.go b/cmd/mesh-controller/checks_test.go index f804eb06..96d134cb 100644 --- a/cmd/mesh-controller/checks_test.go +++ b/cmd/mesh-controller/checks_test.go @@ -1,7 +1,6 @@ package main import ( - "errors" "strings" "testing" @@ -76,8 +75,8 @@ func TestThePullRequestIsMappedOntoTheModuleGraph(t *testing.T) { t.Errorf("%s: %s %q, wanted %q", c.what, what, got[i], want[i]) } } - if s.Mesh != c.mesh || s.Judge != c.judge { - t.Errorf("%s: the mesh's %v judged by %q, wanted %v by %q", c.what, s.Mesh, s.Judge, c.mesh, c.judge) + if s.Mesh != c.mesh { + t.Errorf("%s: the mesh's %v, wanted %v", c.what, s.Mesh, c.mesh) } if s.gated() != (c.modules != "" || c.new != "") { t.Errorf("%s: gated %v", c.what, s.gated()) @@ -95,28 +94,6 @@ func TestAPullRequestTouchesWhatPackagesItsRepository(t *testing.T) { } } -// Each layer is said; a check that could not run is an error on both, never a pass; a build seat from -// before the layers is read as the gate. -func TestAChecksLayersAreEachSaidAndAnErrorIsNeverAPass(t *testing.T) { - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 3, Modules: []string{"gitea"}} - c := checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Failed: "the facts snapshot cannot be read"}) - if c.Verdict != "error" || c.Gate == nil || c.Gate.Verdict != "error" || c.RepoCheck == nil || c.RepoCheck.Verdict != "error" { - t.Fatalf("a check that could not run said %+v", c) - } - if strings.Join(c.Gate.Modules, ",") != "gitea" { - t.Errorf("the gate names %v", c.Gate.Modules) - } - c = checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Check: &link.CheckOutcome{Verdict: "warning", Summary: "wide"}}) - if c.Gate == nil || c.Gate.Verdict != "warning" || c.RepoCheck != nil { - t.Fatalf("an outcome without layers said %+v", c) - } - c = checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Check: &link.CheckOutcome{Verdict: "pass", - Gate: &link.CheckLayer{Verdict: "pass"}, RepoCheck: &link.CheckLayer{Verdict: "fail", Summary: "its merge-check.sh failed"}}}) - if c.RepoCheck.Verdict != "fail" || c.Gate.Verdict != "pass" { - t.Fatalf("the layers said %+v / %+v", c.Gate, c.RepoCheck) - } -} - // **The check asks the planner, never a mapping of its own** (novox/hq ADR 0238): what a pull request // reaches is reachOfMerge's answer — the same that plans a merge — so a file at the root touches no // module in both, and what stands on a touched module is named as its dependents in both. @@ -171,166 +148,5 @@ func TestAPullRequestReachesWhatAMergeOfItWouldPlan(t *testing.T) { } } -// **Only a commit on the trunk is published** (novox/hq ADR 0238): a build of a commit off its repository's -// default branch — a pull request's head, a branch built by hand, a rebuild or replay of one — is recorded -// and never registered, so nothing can send it; a check or a dry run never is either. -func TestABuildOffTheTrunkIsNeverPublished(t *testing.T) { - on := link.BuildResult{ID: "b", Commit: "abc", Trunk: "main", OnTrunk: true} - if err := publishable(on, ""); err != nil { - t.Errorf("a build on the trunk was refused: %v", err) - } - off := link.BuildResult{ID: "b", Commit: "abc", Trunk: "main"} - if err := publishable(off, ""); !errors.Is(err, errOffTheTrunk) || !strings.Contains(err.Error(), "main") { - t.Errorf("a build off the trunk was let through: %v", err) - } - for _, r := range []link.BuildResult{ - {ID: "c", Trunk: "main", OnTrunk: true, Check: &link.CheckOutcome{Verdict: "pass"}}, - {ID: "d", Trunk: "main", OnTrunk: true, Checked: &link.CheckRequest{}}, - {ID: "e", Trunk: "main", OnTrunk: true, DryRun: true}, - } { - if publishable(r, "") == nil { - t.Errorf("%s, a check or a dry run, was publishable", r.ID) - } - } - // A module that follows a branch other than the default: that branch is its trunk, and the default - // is not. - follows := link.BuildResult{ID: "g", Commit: "abc", Trunk: "master", Branches: []string{"nox-mesh"}} - if err := publishable(follows, "nox-mesh"); err != nil { - t.Errorf("a commit on the branch a module follows was refused: %v", err) - } - if err := publishable(link.BuildResult{ID: "h", Commit: "abc", Trunk: "master", OnTrunk: true, - Branches: []string{"master"}}, "nox-mesh"); err == nil { - t.Error("a commit on the default but not on the branch the module follows was published") - } - if err := publishable(link.BuildResult{ID: "i", Commit: "abc", Trunk: "main", Branches: []string{"feat/x"}}, ""); err == nil { - t.Error("a feature branch's commit was published") - } - // A build seat older than the rule says nothing: let through and said, so the rule can reach the mesh. - if err := publishable(link.BuildResult{ID: "f", Commit: "abc"}, ""); err != nil { - t.Errorf("a build seat that said nothing was refused: %v", err) - } -} - -// **A verdict names the trunk commit it judged** (novox/hq ADR 0323 §3, issue 341): the controller's `checked` -// carries the head and the trunk commit the build seat merged it into, the commits of the repositories read beside -// it, how long it took, and the kept delivery plan records the trunk commit with its other inputs. A group's -// composed check names, per member, the trunk commit that head was merged into (§6). -func TestAVerdictNamesTheTrunkCommitItJudged(t *testing.T) { - plan := &link.ChangePlan{Repository: "novox/mesh-host", Base: "main", Head: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - Summary: "builds mesh-host"} - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 66, Base: "main", Plan: plan} - c := checkedOf(link.BuildResult{ID: "b", Ref: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Checked: asked, - Check: &link.CheckOutcome{Verdict: "pass", Took: "4m12s", Head: "dfb1415aaaaa", Trunk: "d28ca95bbbbb", - Gate: &link.CheckLayer{Verdict: "pass"}, RepoCheck: &link.CheckLayer{Verdict: "pass"}, - Beside: map[string]string{"mesh-catalog": "0123456789ab", "mesh-lab": "ba9876543210"}}}) - if c.Trunk != "d28ca95bbbbb" || c.Commit != "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { - t.Fatalf("the verdict names head %s and trunk %q", c.Commit, c.Trunk) - } - if c.Beside["mesh-catalog"] != "0123456789ab" || c.Beside["mesh-lab"] != "ba9876543210" { - t.Fatalf("the verdict names the repositories read beside it as %v", c.Beside) - } - if c.Took != "4m12s" { - t.Fatalf("the verdict says it took %q", c.Took) - } - if c.Plan == nil || c.Plan.Trunk != "d28ca95bbbbb" || c.Plan.Head != plan.Head { - t.Fatalf("the kept plan records %+v", c.Plan) - } - if plan.Trunk != "" { - t.Fatal("the plan the controller asked with was changed: the verdict's is a copy") - } - - group := &link.CheckRequest{Owner: "novox", Repo: "mesh-controller", Number: 7, Base: "main", Group: "feat/x", - Members: []link.GroupMember{{Owner: "novox", Repo: "mesh-host", Number: 8, Ref: "cccccccccccc0000", Base: "main"}}} - c = checkedOf(link.BuildResult{ID: "g", Ref: "aaaaaaaaaaaa0000", Checked: group, Check: &link.CheckOutcome{ - Verdict: "pass", Head: "aaaaaaaaaaaa", Trunk: "111111111111", Gate: &link.CheckLayer{Verdict: "pass"}, - Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-host", Commit: "cccccccccccc", Trunk: "222222222222"}}}}) - if len(c.Members) != 2 || c.Members[0].Trunk != "111111111111" || c.Members[1].Trunk != "222222222222" || - c.Members[1].Commit != "cccccccccccc0000" { - t.Fatalf("the composed check names its members %+v", c.Members) - } -} - -// **A group's composed verdict names every member's trunk commit** (novox/hq issue 1106): a member the build seat named no -// trunk for (an SDK member whose base the seat did not merge, or a seat from before the rule) is named by the trunk the forge -// announced with its head, and the primary the same, so the merge tool never reads "names no trunk commit" on a member -// whose trunk did not move. -func TestAGroupVerdictNamesEveryMembersTrunkTheSDKMemberToo(t *testing.T) { - group := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 7, Base: "main", Group: "feat/x", - Trunk: "dddddddddddd0000", - Members: []link.GroupMember{ - {Owner: "novox", Repo: "mesh-sdk", Number: 8, Ref: "cccccccccccc0000", Base: "main", Trunk: "eeeeeeeeeeee0000"}, - {Owner: "novox", Repo: "mesh-host", Number: 9, Ref: "bbbbbbbbbbbb0000", Base: "main", Trunk: "ffffffffffff0000"}}} - c := checkedOf(link.BuildResult{ID: "g", Ref: "aaaaaaaaaaaa0000", Checked: group, Check: &link.CheckOutcome{ - Verdict: "pass", Head: "aaaaaaaaaaaa", Gate: &link.CheckLayer{Verdict: "pass"}, - Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-host", Commit: "bbbbbbbbbbbb", Trunk: "222222222222"}}}}) - want := []string{"dddddddddddd", "eeeeeeeeeeee", "222222222222"} - if len(c.Members) != 3 { - t.Fatalf("the composed check names its members %+v", c.Members) - } - for i, m := range c.Members { - if m.Trunk != want[i] { - t.Fatalf("member %d (%s/%s) is named with trunk %q, want %q (the seat's word first, else the announced one): %+v", - i, m.Owner, m.Repo, m.Trunk, want[i], c.Members) - } - } -} - -// **The merge made for a check is never registered** (novox/hq ADR 0323 §2): a build of it — a commit on no branch -// of the forge — is refused, naming the trunk. The rule of ADR 0238 §1 stands unchanged; this holds it for the -// merge commit a check makes. -func TestABuildOfACheckMergeIsRefusedNamingTheTrunk(t *testing.T) { - merge := link.BuildResult{ID: "b", Commit: "4b5c6d7e8f90aabbccddeeff0011223344556677", Trunk: "main"} - err := publishable(merge, "") - if !errors.Is(err, errOffTheTrunk) || !strings.Contains(err.Error(), "not on main") { - t.Fatalf("a build of a check's merge was answered %v", err) - } -} - -// **A verdict no build judges names its trunk commit too** (novox/hq ADR 0323 §3): a change touching no module of a -// repository with no merge-check.sh (novox/mesh-tools-go) is answered by the controller itself. Naming no trunk, the -// merge tool refused it, asked again, got the same verdict, and refused for ever. -func TestADirectVerdictNamesTheTrunkTheForgeAnnounced(t *testing.T) { - p := link.PullUpdated{Owner: "novox", Repo: "mesh-tools-go", Number: 3, Base: "main", Commit: "1a2b3c4d5e6f7a8b9c0d", - BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"} - c := directVerdict(p, link.ChangePlan{Repository: "novox/mesh-tools-go", Base: "main", Head: p.Commit}) - if c.Trunk != "7a8b9c0d1e2f" || c.Plan == nil || c.Plan.Trunk != "7a8b9c0d1e2f" { - t.Fatalf("the direct verdict names trunk %q, its plan %+v", c.Trunk, c.Plan) - } - if c.Verdict != "pass" || c.Commit != p.Commit { - t.Fatalf("the direct verdict is %+v", c) - } - if c := directVerdict(link.PullUpdated{Owner: "novox", Repo: "x", Commit: "abc"}, link.ChangePlan{}); c.Trunk != "" { - t.Fatalf("a forge that said no base head gave the trunk %q", c.Trunk) - } -} - -// A head checked again through mesh-delivery (`delivery check --members`, a recheck) reaches the same direct verdict: -// it names the base head the forge last announced, kept on the member, or the merge tool would refuse it for ever. -func TestARecheckedHeadsDirectVerdictNamesItsTrunk(t *testing.T) { - m := orderMember{ID: "novox/mesh-tools-go@1a2b3c4d5e6f", Repository: "novox/mesh-tools-go", Base: "main", - Head: "1a2b3c4d5e6f7a8b9c0d", Number: 3, BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"} - if c := directVerdict(m.pull(), link.ChangePlan{}); c.Trunk != "7a8b9c0d1e2f" { - t.Fatalf("a rechecked head's direct verdict names trunk %q", c.Trunk) - } -} - // A check stopped by its pull request's key moving on says no verdict (issue 974, review 2): an error said for it would name the // live head, and the newer request is the one that judges. A check that failed for any other reason still says its error. - -// C2 (ADR 0513 §4, ADR 0503 §5; issue 1299): a trunk requiring mesh/repo-check has its repository check asked, whether or -// not it also requires mesh/check (hq requires both); a trunk requiring mesh/check alone, or neither, is asked nothing. -func TestARepositoryCheckIsAskedWheneverTheTrunkRequiresIt(t *testing.T) { - for _, c := range []struct { - required []string - asks bool - }{ - {[]string{"mesh/check", "mesh/repo-check"}, true}, - {[]string{"mesh/repo-check"}, true}, - {[]string{"mesh/check"}, false}, - {nil, false}, - } { - if got := asksRepoCheck(c.required); got != c.asks { - t.Fatalf("a trunk requiring %v asks its repository check: %v, want %v", c.required, got, c.asks) - } - } -} diff --git a/cmd/mesh-controller/composed_verdict_test.go b/cmd/mesh-controller/composed_verdict_test.go deleted file mode 100644 index 58e4ee36..00000000 --- a/cmd/mesh-controller/composed_verdict_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -// A delivery group's gate is the composed check's, and its members' own checks run their repository's check alone -// (novox/hq ADR 0403 clauses 1 and 7). Pure: no store, no bus. - -// The heads of a group's members, by repository. -const ( - headHost = "1111111111111111111111111111111111111111" - headController = "2222222222222222222222222222222222222222" -) - -// A composed member's verdict never says its gate passed: the gate layer, and the verdict that is the gate's, read -// "composed"; a failure or an error stays what it was. -func TestAComposedMembersVerdictHasNoPassingGate(t *testing.T) { - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 114, Composed: true} - pass := link.BuildResult{ID: "b", Ref: headHost, Checked: asked, Check: &link.CheckOutcome{Verdict: "pass", Summary: "no module touched", - Gate: &link.CheckLayer{Verdict: "pass", Summary: "no module touched"}, RepoCheck: &link.CheckLayer{Verdict: "pass", Summary: "ok"}}} - c := checkedOf(pass) - if c.Gate == nil || c.Gate.Verdict != "composed" || c.Verdict != "composed" || c.RepoCheck == nil || c.RepoCheck.Verdict != "pass" { - t.Fatalf("the member's verdict says %+v gate %+v", c, c.Gate) - } - asked.Composed = false - if c := checkedOf(pass); c.Gate.Verdict != "pass" || c.Verdict != "pass" { - t.Fatalf("a member of no composed group has its own gate: %+v", c.Gate) - } - asked.Composed = true - failing := pass - failing.Check = &link.CheckOutcome{Verdict: "fail", Summary: "conflict", Gate: &link.CheckLayer{Verdict: "fail", Summary: "conflict"}, - RepoCheck: &link.CheckLayer{Verdict: "fail", Summary: "conflict"}} - if c := checkedOf(failing); c.Gate.Verdict != "fail" || c.Verdict != "fail" { - t.Fatalf("a failure was read as composed: %+v", c.Gate) - } - broken := link.BuildResult{ID: "b", Ref: headHost, Checked: asked, Failed: "the facts snapshot cannot be read"} - if c := checkedOf(broken); c.Verdict != "error" || c.Gate.Verdict != "error" { - t.Fatalf("an error was read as composed: %+v", c) - } -} - -// The composed verdict on the bus carries every member, each with its trunk commit, for the forge's holder to set each -// member's mesh/merge-gate from it. -func TestTheComposedVerdictCarriesEveryMember(t *testing.T) { - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 114, Group: "feat/x", - Members: []link.GroupMember{{Owner: "novox", Repo: "mesh-controller", Number: 346, Ref: headController}}} - c := checkedOf(link.BuildResult{ID: "g", Ref: headHost, Checked: asked, Check: &link.CheckOutcome{Verdict: "pass", Summary: "ok", - Trunk: "aaaaaaaaaaaa", Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-controller", Commit: headController[:12], Trunk: "bbbbbbbbbbbb"}}}}) - body, _ := json.Marshal(c) - for _, want := range []string{`"group":"feat/x"`, `"members":[{"owner":"novox","repo":"mesh-host","number":114,"commit":"` + headHost + `","trunk":"aaaaaaaaaaaa"}`, - `{"owner":"novox","repo":"mesh-controller","number":346,"commit":"` + headController + `","trunk":"bbbbbbbbbbbb"}`} { - if !strings.Contains(string(body), want) { - t.Fatalf("the composed verdict lacks %s:\n%s", want, body) - } - } -} diff --git a/cmd/mesh-controller/delivery.go b/cmd/mesh-controller/delivery.go index 44d6c0f0..cad30ba5 100644 --- a/cmd/mesh-controller/delivery.go +++ b/cmd/mesh-controller/delivery.go @@ -815,18 +815,21 @@ func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[str // delivery walks [-n 50] [--plan ] func deliveryCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("delivery order|check|go|stop|walks") + return errors.New("delivery order|go|stop|walks") } sub, rest := args[0], args[1:] if sub == "plan" { // Removed (ADR 0450 §8; issue 1318): refused by name before any flag is read, naming its replacement. return removedByName("delivery-plan") } + if sub == "check" { + // Removed (ADR 0503 §3, ADR 0527; issue 1318): the checker judges every head, so the controller asks nothing. + return removedByName("delivery-check") + } set := flag.NewFlagSet("delivery "+sub, flag.ContinueOnError) membersJSON := set.String("members", "", "a group's members, as JSON") membersFile := set.String("members-file", "", "a file of a group's members, as JSON: the same as --members, for a list "+ "too long for an argument (novox/hq issue 1272)") - group := set.String("group", "", "a delivery group's id") ready := set.Bool("ready", false, "the head's pull request is marked ready: its check is queued before every other check "+ "(novox/hq ADR 0406 §3)") by := set.String("by", catalogue.DeliverySeat, "who says it") @@ -883,25 +886,6 @@ func deliveryCommand(ctx context.Context, args []string) error { return fmt.Errorf("what the members say of their manifests cannot be read: %w", err) } return answer(orderOf(members, reachOfMembers(members, entries, read, edges), edges, changes)) - case "check": - 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. - return recheckOneHead(members[0].ID, answer, func() (string, error) { - var asked string - err := sayingOnTheBus(ctx, inv, func() (err error) { - asked, err = (following{open: open, oneShot: true}).pullUpdated(ctx, members[0].pull(), os.Stderr) - return err - }) - return asked, err - }) - } - if *group == "" || len(members) < 2 { - return errors.New("delivery check --group --members (two heads or more), or --members ") - } - // **The group's composed check is the checker's** (novox/hq ADR 0503 §3; issue 1299, C2): nothing is asked of - // build-agent, and the answer says who judges it. - return answer(groupCheckAnswer(*group)) case "go": if len(positionals) != 1 { return errors.New("delivery go [--by ] [--why ]") @@ -1076,14 +1060,6 @@ func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry, return entries, read, edges, nil } -// twelve is a commit as twelve characters. -func twelve(c string) string { - if len(c) > 12 { - return c[:12] - } - return c -} - // appendNew appends what is not there yet. func appendNew(to []string, items ...string) []string { for _, i := range items { @@ -1235,32 +1211,3 @@ func overNATS(conn *nats.Conn) (link.Bus, error) { } return link.OverNATS{Conn: conn, JS: js}, nil } - -// recheckAnswer is what `delivery check` answers for one head: the id of the check asked of the build seat (empty when -// the controller said the verdict itself and asked nothing), which mesh-delivery reads, and the head rechecked. -func recheckAnswer(asked, member string) map[string]any { - return map[string]any{"asked": asked, "rechecked": member} -} - -// recheckOneHead runs a one-head `delivery check` and answers it as data. Standard output is the answer's JSON alone, -// or the seat verb reads no answer from it (novox/hq issue 1311): while the check runs, whatever it prints to standard -// output (the controller's own path prints its prose there, as do the stores it reads) goes to standard error. -func recheckOneHead(member string, answer func(any) error, ask func() (string, error)) error { - real := os.Stdout - os.Stdout = os.Stderr - asked, err := func() (string, error) { - defer func() { os.Stdout = real }() - return ask() - }() - if err != nil { - return err - } - return answer(recheckAnswer(asked, member)) -} - -// groupCheckAnswer is what `delivery check` of a group answers from R4e: who judges it, and that nothing was asked -// (novox/hq ADR 0503 §3; issue 1299, C2). -func groupCheckAnswer(group string) map[string]any { - return map[string]any{"asked": "", "group": group, "judged-by": checkerModule, - "said": "the checker judges a delivery group's heads merged together (ADR 0503 §3): nothing was asked"} -} diff --git a/cmd/mesh-controller/delivery_check_answer_test.go b/cmd/mesh-controller/delivery_check_answer_test.go deleted file mode 100644 index c9609ea7..00000000 --- a/cmd/mesh-controller/delivery_check_answer_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -// `delivery check` with one head answers the id of the check it asked, as data (novox/hq issue 1311): the prose -// the check says goes to standard error, so standard output is the document alone and answerOf reads it. -func TestDeliveryCheckOfOneHeadAnswersTheRequestID(t *testing.T) { - argv := []string{"delivery", "check", "--members", "[]"} - doc, err := json.Marshal(recheckAnswer("check-42", "novox/x@abc")) - if err != nil { - t.Fatal(err) - } - // Seen failing first: the prose the command used to print on standard output before the document. - polluted := append([]byte("novox/x#7 (abc): asked the build seat as check-42\n"), doc...) - if got := answerOf(argv, polluted, "", true); got.Answer != nil { - t.Fatalf("prose before the document should leave no answer; got %v", got.Answer) - } - got := answerOf(argv, doc, "novox/x#7 (abc): asked the build seat as check-42\n", true) - m, ok := got.Answer.(map[string]any) - if !ok || m["asked"] != "check-42" || m["rechecked"] != "novox/x@abc" { - t.Fatalf("the answer is %v; want asked check-42 and rechecked novox/x@abc", got.Answer) - } - if !strings.Contains(got.Output, "asked the build seat") { - t.Fatalf("the prose is still said in the text: %q", got.Output) - } -} - -// The text of the other callers does not change: sayChecked writes the same line to the writer it is given, and -// the controller's own path still gives PullUpdated standard output. -func TestSayCheckedWritesTheSameLineToItsWriter(t *testing.T) { - var w bytes.Buffer - sayChecked(context.Background(), &w, link.Checked{ID: "c1", Owner: "novox", Repo: "x", Number: 7, Commit: "0123456789abcdef", - Verdict: "pass", Summary: "fine"}) - want := "c1: novox/x#7 at 01234567 checked on a machine that did not say which: repository none\n" - if w.String() != want { - t.Fatalf("sayChecked said %q; want %q", w.String(), want) - } -} - -// Whatever the check prints to standard output while it runs (checks.go namedForTheCheck, the inventory's pointer -// repair) must not reach the answer's standard output: it is exactly one JSON document (novox/hq issue 1311). -func TestOneHeadRecheckLeavesStandardOutputTheAnswerAlone(t *testing.T) { - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - e, ew, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - realOut, realErr := os.Stdout, os.Stderr - os.Stdout, os.Stderr = w, ew - runErr := recheckOneHead("novox/x@abc", func(v any) error { return json.NewEncoder(os.Stdout).Encode(v) }, - func() (string, error) { - fmt.Println(" the earlier checks' builds could not be read, so the check builds") - fmt.Fprintln(os.Stdout, "repaired a pointer") - return "check-42", nil - }) - os.Stdout, os.Stderr = realOut, realErr - w.Close() - ew.Close() - if runErr != nil { - t.Fatal(runErr) - } - var out, errOut bytes.Buffer - _, _ = out.ReadFrom(r) - _, _ = errOut.ReadFrom(e) - var doc map[string]any - if err := json.Unmarshal(out.Bytes(), &doc); err != nil || doc["asked"] != "check-42" || doc["rechecked"] != "novox/x@abc" { - t.Fatalf("standard output %q is not the answer alone (%v)", out.String(), err) - } - if !strings.Contains(errOut.String(), "repaired a pointer") { - t.Fatalf("the prose should be on standard error: %q", errOut.String()) - } -} diff --git a/cmd/mesh-controller/delivery_conditions.go b/cmd/mesh-controller/delivery_conditions.go index d0a13dc5..c5de9415 100644 --- a/cmd/mesh-controller/delivery_conditions.go +++ b/cmd/mesh-controller/delivery_conditions.go @@ -41,7 +41,7 @@ type stalledLine struct { // waiting for the owner's word and what holds it (novox/hq issues 362, 529). Walk string `json:"walk,omitempty"` // Waiting are the merge checks a line of the state `unanswered` waits on, as mesh-delivery says each: - // "mesh/merge-gate pending since ", "mesh/repo-check never set" (novox/hq issue 438). + // "mesh/check pending since ", "mesh/check never set" (novox/hq issue 438). Waiting []string `json:"waiting,omitempty"` // Checks are the same merge checks as data, which the controller words in the operator's own time (novox/hq // issue 443): a time inside a finished sentence cannot be said again in another zone. A mesh-delivery from before diff --git a/cmd/mesh-controller/delivery_test.go b/cmd/mesh-controller/delivery_test.go index 30478a90..05cfc7a3 100644 --- a/cmd/mesh-controller/delivery_test.go +++ b/cmd/mesh-controller/delivery_test.go @@ -136,8 +136,6 @@ func TestTheDeliveryVerbsComposeTheirCommands(t *testing.T) { {"delivery-walks", map[string]any{}, []string{"delivery", "walks"}}, {"delivery-walks", map[string]any{"plan": "plan-1"}, []string{"delivery", "walks", "--plan", "plan-1"}}, {"delivery-order", map[string]any{"members": "[]"}, []string{"delivery", "order", "--members", "[]"}}, - {"delivery-check", map[string]any{"group": "feat/x", "members": "[]"}, - []string{"delivery", "check", "--group", "feat/x", "--members", "[]"}}, {"plans", map[string]any{"go": "plan-1", "why": "down"}, []string{"plans", "go", "plan-1", "--why", "down"}}, } { got, err := argvFor(c.verb, c.args) diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index fc2855c2..475d33fb 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -669,7 +669,7 @@ func localDoctor(ctx context.Context) (*doctor, func(), error) { if err != nil { return nil, nil, err } - js, err := dialTheBus() + js, err := aBus() if err != nil { open.Close() return nil, nil, err diff --git a/cmd/mesh-controller/dry_run_integration_test.go b/cmd/mesh-controller/dry_run_integration_test.go deleted file mode 100644 index a8cab99e..00000000 --- a/cmd/mesh-controller/dry_run_integration_test.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -func TestADryRunsOutcomeIsTakenInByNothing(t *testing.T) { - err := builds{}.Built(t.Context(), link.BuildResult{ - ID: "build-1", Repository: "ssh://forge/app.git", Ref: "unreviewed", Module: "app", DryRun: true, - Manifest: json.RawMessage(`{"module":"app","version":"1"}`), - }) - if err != nil { - t.Fatalf("a dry run's outcome was not simply set aside: %v", err) - } -} diff --git a/cmd/mesh-controller/dry_run_test.go b/cmd/mesh-controller/dry_run_test.go deleted file mode 100644 index 53767a0a..00000000 --- a/cmd/mesh-controller/dry_run_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "encoding/json" - "testing" - - "github.com/novox/mesh-controller/internal/link" -) - -// A dry run's outcome is looked at, never taken in (novox/hq issue 240). The daemon here holds no -// store at all, so anything that tried to record or register would fail rather than pass quietly. - -// The mark survives the wire both ways: asked as a dry run, answered as one. -func TestTheDryRunMarkTravelsWithTheBuild(t *testing.T) { - raw, _ := json.Marshal(link.BuildRequest{ID: "build-1", Repository: "r", DryRun: true}) - var asked link.BuildRequest - if err := json.Unmarshal(raw, &asked); err != nil || !asked.DryRun { - t.Fatalf("the request lost its dry-run mark: %s", raw) - } - raw, _ = json.Marshal(link.BuildResult{ID: "build-1", DryRun: true}) - var answered link.BuildResult - if err := json.Unmarshal(raw, &answered); err != nil || !answered.DryRun { - t.Fatalf("the outcome lost its dry-run mark: %s", raw) - } - raw, _ = json.Marshal(link.BuildResult{ID: "build-2"}) - if string(raw) != `{"id":"build-2","repository":"","on":""}` { - t.Fatalf("an ordinary outcome carries a dry-run mark: %s", raw) - } -} diff --git a/cmd/mesh-controller/durations.go b/cmd/mesh-controller/durations.go index 36afadc7..b03db5ce 100644 --- a/cmd/mesh-controller/durations.go +++ b/cmd/mesh-controller/durations.go @@ -12,7 +12,6 @@ import ( "time" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // What the core's bounds are set from (novox/hq to-be 45 Phase 0). @@ -24,25 +23,6 @@ import ( // controller as it hears each — a send's first report, a machine's next word, a plan leaving a tier, a // build's outcome — and summarised here per machine, repository or module. -// recordBuildDuration measures one build from its ask to its outcome heard. -func recordBuildDuration(ctx context.Context, inv *inventory.Inventory, result link.BuildResult, asked time.Time) { - if asked.IsZero() || result.ID == "" { - return - } - subject := result.Module - if subject == "" { - subject = result.Repository - } - detail := "built" - if result.Failed != "" { - detail = "failed: " + firstLine(result.Failed) - } - if err := inv.RecordDuration(ctx, inventory.Duration{Kind: inventory.DurationBuild, Subject: subject, - Node: result.On, Ref: result.ID, Started: asked, Took: time.Since(asked), Detail: detail}); err != nil { - fmt.Fprintf(os.Stderr, "%s: how long it took could not be recorded: %v\n", result.ID, err) - } -} - // durationSummary is one subject's measurements of one kind. type durationSummary struct { Kind string `json:"kind"` diff --git a/cmd/mesh-controller/facts.go b/cmd/mesh-controller/facts.go index 32a3b3dd..4cb07134 100644 --- a/cmd/mesh-controller/facts.go +++ b/cmd/mesh-controller/facts.go @@ -19,7 +19,6 @@ import ( snapshot "git.novox.be/novox/mesh-sdk/go/facts" "github.com/novox/mesh-controller/internal/artifacts" "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -241,17 +240,6 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot scrub.Site(o.Site) } - // What a merge check runs in and reads beside it, as the build seat would be asked for it. - if held, err := inv.Held(ctx); err == nil { - for language, reference := range builder.ToolchainsOf(held) { - if f.Versions.Toolchains == nil { - f.Versions.Toolchains = map[string]string{} - } - f.Versions.Toolchains[language] = catalogue.Recorded(reference) - } - } else { - return snapshot.Facts{}, err - } for _, e := range entries { if dir, core := coreModules[e.Manifest.Module]; core && !e.Provided && e.Source.Repository != "" { for d, ref := range besideRefs(dir, current[e.Manifest.Module].Commit) { diff --git a/cmd/mesh-controller/forge_address_test.go b/cmd/mesh-controller/forge_address_test.go index a793bb06..c267b525 100644 --- a/cmd/mesh-controller/forge_address_test.go +++ b/cmd/mesh-controller/forge_address_test.go @@ -64,7 +64,7 @@ func registerFrom(t *testing.T, forms *sourceForms) (int64, error) { mesh := &aMeshRegisteredFromTheURL{anEmptyMesh{asked: true, request: inventory.BuildRequest{ID: "build-1", Repository: aRepoOnTheForge, For: "build"}}, aRepoOnTheForge} return mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", inventory.Source{Repository: aRepoOnTheForge}, - byBuild("build-1"), "", "main") + registrationAsk{Build: "build-1"}, "", "main") } // 1277/AC2: an address unread twice, then read, is waited out and the forge is asked. @@ -112,7 +112,7 @@ func TestAKnownAddressAndAnotherHostIsStillNotOnTheForge(t *testing.T) { const elsewhere = "http://elsewhere.example/x/y.git" mesh := &aMeshRegisteredFromTheURL{anEmptyMesh{asked: true, request: inventory.BuildRequest{ID: "build-1", For: "build"}}, elsewhere} _, err := mayRegisterIn(t.Context(), mesh, sourceFormsRead(context.Background(), time.Now, read), "mesh-delivery-view", - inventory.Source{Repository: elsewhere}, byBuild("build-1"), "", "main") + inventory.Source{Repository: elsewhere}, registrationAsk{Build: "build-1"}, "", "main") if !errors.Is(err, errNotItsSource) || !strings.Contains(err.Error(), "is not on the mesh's forge") { t.Fatalf("1277/AC4: the refusal changed: %v", err) } diff --git a/cmd/mesh-controller/gate_integration_test.go b/cmd/mesh-controller/gate_integration_test.go index 31e5c56d..f0357293 100644 --- a/cmd/mesh-controller/gate_integration_test.go +++ b/cmd/mesh-controller/gate_integration_test.go @@ -444,8 +444,12 @@ func TestAMergeThatDeletesAModulePlansNothingToBuildForIt(t *testing.T) { if err := inv.SavePlan(ctx, &plan); err != nil { t.Fatal(err) } - planBuilt(ctx, open, "kept", "", "http://forge/novox/mesh-catalog.git has no module.json at modules/kept, so "+ - "there is nothing saying what it is: open …/module.json: no such file or directory", asked0, "build-k") + if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-k", Module: "kept", Repository: "novox/mesh-catalog", + At: time.Now().UTC(), Asked: asked0, Failed: "http://forge/novox/mesh-catalog.git has no module.json at modules/kept, so " + + "there is nothing saying what it is: open …/module.json: no such file or directory"}); err != nil { + t.Fatal(err) + } + advancePlans(ctx, open) p, _ := inv.PlanByID(ctx, "plan-deleted") if p.State == inventory.PlanFailed || p.Modules["kept"].State != planDeleted { t.Fatalf("a module deleted at its source failed the plan: %s %+v", p.State, p.Modules["kept"]) diff --git a/cmd/mesh-controller/grants.go b/cmd/mesh-controller/grants.go new file mode 100644 index 00000000..b81ddd6b --- /dev/null +++ b/cmd/mesh-controller/grants.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/inventory" +) + +// The grants issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): what the assigner's sixth rule reads, +// that a module's bus credential for a fingerprint exists. A module's permissions on the bus are what the bus's user list +// composes from its registered manifest (inv.BusRecords), and a module's manifest is registered at a fingerprint when the +// controller takes it in from the builder's key; so once the list is sent to the machine that runs the bus, the +// credential of every module it carried is issued at the fingerprint of the build it is registered at. The controller +// writes `.` then, naming the accounts it composed for the module, never removed. A module one +// of whose accounts has no password yet (the list leaves it out) has no grant until a later send carries it. + +// grantsFrom is the grant of every module whose accounts all have a password in hashes, at the fingerprint current names +// for it; a module whose current build names no fingerprint of the new form has none. principals are the bus's users +// as broker.Users composes them. +func grantsFrom(principals []broker.Principal, hashes map[string]string, current map[string]string) map[string]sdkdelivery.ControllerGrant { + accounts, missing := map[string][]string{}, map[string]bool{} + for _, p := range principals { + if p.Module == "" || (p.Kind != broker.KindModule && p.Kind != broker.KindProcess) { + continue + } + if hashes[p.Username()] == "" { + missing[p.Module] = true + continue + } + accounts[p.Module] = append(accounts[p.Module], p.Username()) + } + out := map[string]sdkdelivery.ControllerGrant{} + for module, fp := range current { + if missing[module] || !strings.HasPrefix(fp, sdkdelivery.SourcePrefix2) { + continue + } + key, err := sdkdelivery.ControllerGrantKey(module, fp) + if err != nil { + continue + } + names := accounts[module] + sort.Strings(names) + out[key] = sdkdelivery.ControllerGrant{Accounts: names} + } + return out +} + +// currentFingerprints is, by module, the fingerprint of the build each registered module is registered at: its +// manifest's commit (built_from), that commit's newest successful build, and the fingerprint that build names. +func currentFingerprints(ctx context.Context, inv *inventory.Inventory) (map[string]string, error) { + builds, err := inv.CurrentBuilds(ctx) + if err != nil { + return nil, err + } + out := map[string]string{} + for module, b := range builds { + if b.Commit == "" { + continue + } + id, err := inv.BuildOf(ctx, module, b.Commit) + if err != nil || id == "" { + continue + } + build, found, err := inv.BuildByID(ctx, id) + if err != nil || !found { + continue + } + out[module] = build.SourceFingerprint + } + return out, nil +} + +// grantsKV is what writing the grants needs of their bucket. +type grantsKV interface { + Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) + Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) + Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) +} + +// keepGrants writes every grant the list just sent issued that the bucket does not hold as it is: a create for a new one, +// a compare-and-set for one whose accounts changed. It answers how many it wrote. +func keepGrants(ctx context.Context, kv grantsKV, grants map[string]sdkdelivery.ControllerGrant) (int, error) { + keys := make([]string, 0, len(grants)) + for k := range grants { + keys = append(keys, k) + } + sort.Strings(keys) + wrote := 0 + var errs []error + for _, key := range keys { + value, err := sdkdelivery.EncodeControllerGrant(grants[key]) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", key, err)) + continue + } + e, err := kv.Get(ctx, key) + switch { + case errors.Is(err, jetstream.ErrKeyNotFound): + _, err = kv.Create(ctx, key, value) + case err != nil: + default: + if before, derr := sdkdelivery.DecodeControllerGrant(e.Value()); derr == nil && sdkdelivery.SameControllerGrant(before, withVersion(grants[key])) { + continue + } + _, err = kv.Update(ctx, key, value, e.Revision()) + } + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", key, err)) + continue + } + wrote++ + } + return wrote, errors.Join(errs...) +} + +func withVersion(g sdkdelivery.ControllerGrant) sdkdelivery.ControllerGrant { + g.V = sdkdelivery.Version + return g +} + +// grantsIssued writes the grants the bus's user list just sent to its machine issued (ADR 0528 clause 2). Said, never +// fatal: a grant not written now is written at the next send of the list, and the assigner's wants wait for it. +func grantsIssued(ctx context.Context, inv *inventory.Inventory, js jetstream.JetStream, indent string) { + kv, err := js.KeyValue(ctx, broker.GrantsBucket) + if err != nil { + fmt.Printf("%sthe grants issued are not written: the bucket %s: %v\n", indent, broker.GrantsBucket, err) + return + } + records, err := inv.BusRecords(ctx) + if err != nil { + fmt.Printf("%sthe grants issued are not written: %v\n", indent, err) + return + } + principals, err := broker.Users(records) + if err != nil { + fmt.Printf("%sthe grants issued are not written: %v\n", indent, err) + return + } + kept, err := inv.BusUsers(ctx) + if err != nil { + fmt.Printf("%sthe grants issued are not written: %v\n", indent, err) + return + } + hashes := make(map[string]string, len(kept)) + for name, u := range kept { + hashes[name] = u.PasswordHash + } + current, err := currentFingerprints(ctx, inv) + if err != nil { + fmt.Printf("%sthe grants issued are not written: %v\n", indent, err) + return + } + wrote, err := keepGrants(ctx, kv, grantsFrom(principals, hashes, current)) + if wrote > 0 { + fmt.Printf("%s%d grant(s) issued written for the assigner\n", indent, wrote) + } + if err != nil { + fmt.Printf("%ssome grants issued are not written: %v\n", indent, err) + } +} diff --git a/cmd/mesh-controller/grants_integration_test.go b/cmd/mesh-controller/grants_integration_test.go new file mode 100644 index 00000000..3180a764 --- /dev/null +++ b/cmd/mesh-controller/grants_integration_test.go @@ -0,0 +1,91 @@ +//go:build integration + +package main + +import ( + "context" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/testbus" +) + +// 1394/AC1 of C9 (ADR 0528 clause 2): when the controller has issued mesh-x's credential for fingerprint F (mesh-x +// registered at a build of F, its account minted, the bus's machine sent its declaration with the user list, through +// overTheBus.declare as a push sends it), then grants.mesh-x.F exists on the bus, +// and a second pass writes it again as nothing new. +func TestTheGrantOfAnIssuedCredentialIsOnTheBus(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + fp := grantFP('d') + m := catalogue.Manifest{Module: "mesh-x", Version: "1", OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh-x/broker"}}} + if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-x1", Repository: "novox/mesh-x", Module: "mesh-x", Commit: "c0ffee1", + SourceFingerprint: fp}); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-x", BuiltFrom: "c0ffee1", Head: "c0ffee1"}); err != nil { + t.Fatal(err) + } + if _, err := assign(ctx, open, "anchor", "mesh-x"); err != nil { + t.Fatal(err) + } + user := broker.Principal{Kind: broker.KindModule, Node: "anchor", Module: "mesh-x"}.Username() + if _, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: user, Kind: inventory.BusModule, Node: "anchor", Module: "mesh-x"}); err != nil { + t.Fatal(err) + } + conn, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(conn.Close) + js, err := jetstream.New(conn) + if err != nil { + t.Fatal(err) + } + if err := broker.OnConn(conn).EnsureControllerBuckets(); err != nil { + t.Fatal(err) + } + // The live path: a send of the bus's machine's declaration carrying the user list (overTheBus.declare), which records + // the list sent and then writes the grants it issued. + legacy, err := conn.JetStream() + if err != nil { + t.Fatal(err) + } + _ = js.DeleteStream(ctx, "GRANTS_DECLARED") + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "GRANTS_DECLARED", Subjects: []string{link.DeclareSubject("anchor")}}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = js.DeleteStream(context.Background(), "GRANTS_DECLARED") }) + send := overTheBus{open: open, server: link.SendingOn(link.OverNATS{Conn: conn, JS: legacy}), signer: aSignerOfZeros{}} + ready := readyNode{node: "anchor", declared: sendable{BusUsers: "the user list the bus's machine is sent"}} + if _, err := send.declare(ctx, ready, []byte(`{}`)); err != nil { + t.Fatalf("the send of the bus's machine: %v", err) + } + kv, err := js.KeyValue(ctx, broker.GrantsBucket) + if err != nil { + t.Fatal(err) + } + key, _ := sdkdelivery.ControllerGrantKey("mesh-x", fp) + e, err := kv.Get(ctx, key) + if err != nil { + t.Fatalf("no grant %s after the list was issued: %v", key, err) + } + g, err := sdkdelivery.DecodeControllerGrant(e.Value()) + if err != nil || len(g.Accounts) != 1 || g.Accounts[0] != user { + t.Fatalf("the grant reads %+v, %v; want the account %s", g, err, user) + } + if _, err := send.declare(ctx, ready, []byte(`{}`)); err != nil { + t.Fatalf("the second send: %v", err) + } + if again, _ := kv.Get(ctx, key); again.Revision() != e.Revision() { + t.Errorf("a second pass wrote the grant again (revision %d, was %d)", again.Revision(), e.Revision()) + } +} diff --git a/cmd/mesh-controller/grants_step_test.go b/cmd/mesh-controller/grants_step_test.go index c484d693..c8502758 100644 --- a/cmd/mesh-controller/grants_step_test.go +++ b/cmd/mesh-controller/grants_step_test.go @@ -9,9 +9,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // The grants reach the bus before the gate (novox/hq issue 490). @@ -176,35 +174,3 @@ func TestTheGrantsStepKeepsOneMachinesBuilds(t *testing.T) { t.Error("the grants step keeps the builds of a machine it does not send") } } - -// The delivery plan says the step before the merge. -func TestAChangePlanSaysTheGrantsStep(t *testing.T) { - const catalogue_ = "http://forge.internal:20000/novox/mesh-catalog.git" - entry := func(name string, on ...string) inventory.Entry { - e := fromRepo(name, catalogue_, "modules/"+name) - e.Source.BuiltFrom = "old" - e.On = on - return e - } - nats := entry("nats", "novox") - nats.Manifest.BusUsers = "/etc/nats/users.conf" - nats.Manifest.Claims = []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}} - restic := entry("restic", "ace", "novox", "shanks") - only := entry("bus-tools", "novox") - entries := []inventory.Entry{nats, restic, only} - rollsOut := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true } - plan := func(paths ...string) link.ChangePlan { - m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "head", Paths: paths} - return changePlanOf("novox/mesh-catalog", "main", "head", reachOfMerge(m, entries, nil, nil), entries, rollsOut) - } - - text := planText(plan("modules/restic/module.json")) - if !strings.Contains(text, "grants step: if this changes what the bus's user list says (a new tool, subject or "+ - "user), novox is sent that list alone, every build there kept, before restic is judged on its first machine") { - t.Errorf("the change plan does not say the grants step:\n%s", text) - } - // A module only on the bus's machine goes there with the list in its own send: no step of its own. - if text := planText(plan("modules/bus-tools/module.json")); strings.Contains(text, "grants step") { - t.Errorf("a module on the bus's machine alone reads:\n%s", text) - } -} diff --git a/cmd/mesh-controller/grants_test.go b/cmd/mesh-controller/grants_test.go new file mode 100644 index 00000000..b14715ef --- /dev/null +++ b/cmd/mesh-controller/grants_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" + + "github.com/novox/mesh-controller/internal/broker" +) + +func grantFP(c byte) string { return sdkdelivery.SourcePrefix2 + strings.Repeat(string(c), 64) } + +// 1394/AC (unit half): the list sent issues the credential of every module whose accounts all have a password, at the +// fingerprint its build names; a module with an account the list leaves out, and one registered at a fingerprint not of +// the new form, get no grant; a module with no account of its own is granted with none. +func TestTheGrantsAListIssues(t *testing.T) { + x := broker.Principal{Kind: broker.KindModule, Node: "novox", Module: "mesh-x"} + xp := broker.Principal{Kind: broker.KindProcess, Node: "novox", Module: "mesh-x", Process: "run"} + y := broker.Principal{Kind: broker.KindModule, Node: "ace", Module: "mesh-y"} + node := broker.Principal{Kind: broker.KindNode, Node: "novox"} + hashes := map[string]string{x.Username(): "h1", xp.Username(): "h2", node.Username(): "h3"} + got := grantsFrom([]broker.Principal{x, xp, y, node}, hashes, + map[string]string{"mesh-x": grantFP('a'), "mesh-y": grantFP('b'), "web": grantFP('c'), "old": "src1:abc"}) + xKey, _ := sdkdelivery.ControllerGrantKey("mesh-x", grantFP('a')) + webKey, _ := sdkdelivery.ControllerGrantKey("web", grantFP('c')) + if len(got) != 2 { + t.Fatalf("grants = %v, want mesh-x's and web's", got) + } + if g := got[xKey]; len(g.Accounts) != 2 || g.Accounts[0] != x.Username() && g.Accounts[1] != x.Username() { + t.Errorf("mesh-x's grant = %+v, want both its accounts", g) + } + if g, ok := got[webKey]; !ok || len(g.Accounts) != 0 { + t.Errorf("web, with no account of its own, is granted %+v, %v", g, ok) + } +} + +// fakeGrants is the grants bucket in memory. +type fakeGrants struct { + values map[string][]byte + creates int + updates int +} + +type fakeEntry struct { + key string + value []byte + rev uint64 +} + +func (e fakeEntry) Bucket() string { return broker.GrantsBucket } +func (e fakeEntry) Key() string { return e.key } +func (e fakeEntry) Value() []byte { return e.value } +func (e fakeEntry) Revision() uint64 { return e.rev } +func (e fakeEntry) Created() time.Time { return time.Time{} } +func (e fakeEntry) Delta() uint64 { return 0 } +func (e fakeEntry) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePut } + +func (f *fakeGrants) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) { + v, ok := f.values[key] + if !ok { + return nil, jetstream.ErrKeyNotFound + } + return fakeEntry{key: key, value: v, rev: 1}, nil +} + +func (f *fakeGrants) Create(_ context.Context, key string, value []byte, _ ...jetstream.KVCreateOpt) (uint64, error) { + if _, ok := f.values[key]; ok { + return 0, jetstream.ErrKeyExists + } + f.values[key] = value + f.creates++ + return 1, nil +} + +func (f *fakeGrants) Update(_ context.Context, key string, value []byte, _ uint64) (uint64, error) { + f.values[key] = value + f.updates++ + return 2, nil +} + +// 1394/AC (unit half): a grant is created once; a second send of the same list writes nothing; a reissue for other +// accounts is a new revision of the same key. +func TestAGrantIsWrittenOnceAndMovedOnAReissue(t *testing.T) { + kv := &fakeGrants{values: map[string][]byte{}} + key, _ := sdkdelivery.ControllerGrantKey("mesh-x", grantFP('a')) + grants := map[string]sdkdelivery.ControllerGrant{key: {Accounts: []string{"novox.mesh-x"}}} + for range 2 { + if _, err := keepGrants(t.Context(), kv, grants); err != nil { + t.Fatal(err) + } + } + if kv.creates != 1 || kv.updates != 0 { + t.Errorf("creates %d, updates %d, want one create", kv.creates, kv.updates) + } + grants[key] = sdkdelivery.ControllerGrant{Accounts: []string{"ace.mesh-x"}} + if wrote, err := keepGrants(t.Context(), kv, grants); err != nil || wrote != 1 || kv.updates != 1 { + t.Errorf("a reissue wrote %d (%v), updates %d", wrote, err, kv.updates) + } + if g, err := sdkdelivery.DecodeControllerGrant(kv.values[key]); err != nil || g.Accounts[0] != "ace.mesh-x" { + t.Errorf("the grant reads %+v, %v", g, err) + } +} diff --git a/cmd/mesh-controller/group_check_test.go b/cmd/mesh-controller/group_check_test.go deleted file mode 100644 index 080f1ba1..00000000 --- a/cmd/mesh-controller/group_check_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import "testing" - -// C2 (ADR 0503 §3; issue 1299): a delivery group's composed check is answered as the checker's, and nothing is asked -// of build-agent: the command answers before any ask is composed. -func TestTheGroupCheckNamesTheCheckerAndAsksNothing(t *testing.T) { - a := groupCheckAnswer("feat/x") - if a["judged-by"] != "mesh-delivery-checker" || a["asked"] != "" || a["group"] != "feat/x" { - t.Fatalf("the group check answered %v", a) - } -} diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index 8fb90f96..97e34a25 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -64,6 +64,9 @@ var handActVerbs = []handActVerb{ {Verb: "plans stop", Decision: "the operator's answer to an ask is their decision, not a repair (ADR 0259)", DecidedFor: []string{conditions.CauseOperatorAnswer}}, {Verb: "plans close"}, + // A module registered at references by hand, because the builder had no key for it (ADR 0526 clause 3): a repair + // the builder should have made, so it counts. + {Verb: "register"}, // A walk started by a person instead of its delivery's owner (novox/hq ADR 0239): the owner down, or // not trusted with it — either is a repair the owner should have made. Unless the operator chose it on // a warrant (ADR 0259). diff --git a/cmd/mesh-controller/issue529_test.go b/cmd/mesh-controller/issue529_test.go index d8cc2fb1..444d33ab 100644 --- a/cmd/mesh-controller/issue529_test.go +++ b/cmd/mesh-controller/issue529_test.go @@ -31,7 +31,7 @@ func TestAWalkWaitingOnAHeldDeliveryNamesTheHold(t *testing.T) { "check did not pass for the heads that merged — a person decides: `mesh-delivery.release` (id " + "novox/mesh-sdk@d41d255f28ad, why) lets it go, `mesh-delivery.stop` ends it" waits := walkWaits{p.ID: {{ID: "novox/mesh-sdk@d41d255f28ad", State: "held", Walk: p.ID, Says: held}}} - line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, waits) + line := planLineSaid(p, now, tierAtLeast, nil, waits) for _, want := range []string{"held for a person", "composed check did not pass", "mesh-delivery.release"} { if !strings.Contains(line, want) { t.Fatalf("the line does not say %q: %q", want, line) @@ -41,7 +41,7 @@ func TestAWalkWaitingOnAHeldDeliveryNamesTheHold(t *testing.T) { t.Fatalf("a held walk's line points at a hand start: %q", line) } // Told nothing — an older mesh-delivery, or none answering — it reads as before. - if line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, nil); !strings.Contains(line, "starts it by hand") { + if line := planLineSaid(p, now, tierAtLeast, nil, nil); !strings.Contains(line, "starts it by hand") { t.Fatalf("told nothing, the line reads %q", line) } } @@ -51,7 +51,7 @@ func TestAWalkWaitingOnADeliveryNotAtItsTurnSaysSo(t *testing.T) { p := waitingWalk(now) waits := walkWaits{p.ID: {{ID: "novox/mesh-sdk@9747f3e1b1ac", State: "published", Walk: p.ID, Says: "not let go yet: novox/mesh-tools@3f0c1a6cec18 (published) carried by it not at its turn"}}} - line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, waits) + line := planLineSaid(p, now, tierAtLeast, nil, waits) if !strings.Contains(line, "not at its turn") || !strings.Contains(line, "novox/mesh-tools@3f0c1a6cec18") { t.Fatalf("the line reads %q", line) } @@ -69,7 +69,7 @@ func TestAWalkHeldByNothingSaysWhyAndTheHandStart(t *testing.T) { waits := walkWaits{p.ID: {{ID: "novox/mesh-media-catalog@cdcdcdcd0000", State: "unheld", Walk: p.ID, Says: "held by nothing: its merges' deliveries ended as nothing-to-move (novox/mesh-media-catalog@abababab0000), " + "yet the walk moves nzbgeek, nzbget — nothing gives it its word"}}} - line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, waits) + line := planLineSaid(p, now, tierAtLeast, nil, waits) if !strings.Contains(line, "ended as nothing-to-move") || !strings.Contains(line, "plans go "+p.ID) { t.Fatalf("the line reads %q", line) } diff --git a/cmd/mesh-controller/key_takein.go b/cmd/mesh-controller/key_takein.go index 67423633..ce5d2ff3 100644 --- a/cmd/mesh-controller/key_takein.go +++ b/cmd/mesh-controller/key_takein.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "path" + "sort" "strings" "time" @@ -91,16 +92,42 @@ var predefinedAtTrunk = func(ctx context.Context, inv *inventory.Inventory) (pre if err != nil { return predefinedHead{}, err } - repository := "" + return readPredefinedHead(ctx, entries, formsOfSources(ctx, inv)) +} + +// formsOfSources reads how the sources are spelled on this mesh; a variable so a test names the forge's address. +var formsOfSources = newSourceForms + +// errNoCatalogueRepository says no module of the mesh is built from the catalogue's repository on the forge: not a wait, +// since no read of the forge makes one appear (issue 1902); the plan fails with these words. +var errNoCatalogueRepository = errors.New("no module of the catalogue names the repository " + + catalogue.CatalogueRepository + " on the forge") + +// catalogueRepositoryAmong is the catalogue's repository on the forge, as owner/name, found as catch-up finds the trunks +// of the repositories the catalogue builds from: a source in the URL form or the seat form, `.git` dropped (issue 1902). +func catalogueRepositoryAmong(entries []inventory.Entry, forms *sourceForms) (string, error) { for _, e := range entries { - if e.Source.Seat == gitSeat && path.Base(strings.Trim(e.Source.Repository, "/")) == catalogue.CatalogueRepository { - repository = strings.Trim(e.Source.Repository, "/") - break + if e.Source.Repository == "" { + continue + } + owner, name, onForge := forms.onTheForge(e.Source.Repository, e.Source.Seat) + if onForge && strings.EqualFold(name, catalogue.CatalogueRepository) { + return owner + "/" + name, nil } } - if repository == "" { - return predefinedHead{}, fmt.Errorf("no module of the catalogue names the repository %s on the forge", - catalogue.CatalogueRepository) + return "", errNoCatalogueRepository +} + +// readPredefinedHead is the list at the trunk head of the catalogue's repository, found among the modules' sources and +// read from the forge. +func readPredefinedHead(ctx context.Context, entries []inventory.Entry, forms *sourceForms) (predefinedHead, error) { + // A URL source is on the forge only once the forge's address is known; an address that cannot be read is a wait. + if _, err := forms.forgeAddress(ctx, forgeAddressWait); err != nil { + return predefinedHead{}, fmt.Errorf("the forge's address cannot be read: %w", err) + } + repository, err := catalogueRepositoryAmong(entries, forms) + if err != nil { + return predefinedHead{}, err } var heads []struct { SHA string `json:"sha"` @@ -142,7 +169,7 @@ var predefinedAtTrunk = func(ctx context.Context, inv *inventory.Inventory) (pre } // askGitea asks the forge's module one tool on the bus and reads its result into into. -func askGitea(ctx context.Context, tool string, args map[string]any, into any) error { +var askGitea = func(ctx context.Context, tool string, args map[string]any, into any) error { js, err := aBus() if err != nil { return err @@ -171,6 +198,10 @@ type keyTake struct { Module string Trunk sdkdelivery.Module Ask registrationAsk + // Hand names, by artifact, the references the controller's terminal registers the module at instead of a key's + // (ADR 0526 clause 3, `register --reference`): the key, when the builder has one, supplies the artifacts not named + // here. Nil for every take-in but that one. + Hand map[string]string } // takeInKey takes a module in from the builder's key for the fingerprint its trunk value names: records the key as a @@ -178,6 +209,10 @@ type keyTake struct { func takeInKey(ctx context.Context, inv *inventory.Inventory, keys keyReads, t keyTake) ( catalogue.Manifest, inventory.Build, error) { head, err := predefinedAtTrunk(ctx, inv) + if errors.Is(err, errNoCatalogueRepository) { + // Fails the plan, loudly, with these words (issue 1902): waiting would never end. + return catalogue.Manifest{}, inventory.Build{}, fmt.Errorf("%s is not judged: %w", t.Module, err) + } if err != nil { return catalogue.Manifest{}, inventory.Build{}, fmt.Errorf("%w: the list of predefined keys at the catalogue's "+ "trunk head cannot be read, so %s is not judged: %v", errKeyWaits, t.Module, err) @@ -234,7 +269,7 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) ( waits := func(format string, a ...any) (inventory.Build, catalogue.Manifest, error) { return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("%w: "+format, append([]any{errKeyWaits}, a...)...) } - if t.Trunk.FP2 == "" { + if t.Trunk.FP2 == "" && t.Hand == nil { why := t.Trunk.None2 if why == "" { why = "the indexer wrote no fingerprint of the new form" @@ -242,6 +277,12 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) ( return waits("%s at %.8s has no fingerprint to take a build by: %s", t.Module, t.Trunk.Commit, why) } k, err := keys.Build(ctx, t.Module, t.Trunk.FP2) + if t.Hand != nil { + // **Registered at a reference, no key needed** (ADR 0526 clause 3): the key the builder holds, if any, is the + // start; the references given replace or add its artifacts, and the key counts as done. + k = handKey(t, k) + err = nil + } if err != nil { return waits("%s's key cannot be read: %v", t.Module, err) } @@ -272,6 +313,11 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) ( return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("the key of %s names the manifest of %s", t.Module, manifest.Module) } + if t.Hand != nil { + if err := handCoversEvery(manifest, k, t.Hand); err != nil { + return inventory.Build{}, catalogue.Manifest{}, err + } + } built, err := builtOfKey(ctx, kc.archive, manifest, k) if err != nil { return waits("%v", err) @@ -308,6 +354,10 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) ( break } } + if t.Hand != nil { + // A build made by hand, said so: the id is its own, so it never reads as the builder's key. + kept.ID, kept.On = "hand-"+keyBuildID(k)+"-"+time.Now().UTC().Format("20060102T150405Z"), "hand" + } for _, a := range k.Build.Artifacts { kept.Made = append(kept.Made, inventory.Artifact{Name: a.Name, Kind: kindOf(manifest, a), Reference: catalogue.Recorded(a.Reference)}) @@ -458,3 +508,66 @@ func pathsBuildSource(m catalogue.Manifest, dir string) []string { } return out } + +// handKey is the key a registration at references is made from: the builder's key when it has one (its other artifacts +// stand), with each reference given in place of or beside the key's artifact of that name, and counted done. +func handKey(t keyTake, k builderkeys.Key) builderkeys.Key { + if !k.Found { + k = builderkeys.Key{Module: t.Module, Fingerprint: t.Trunk.FP2, Found: true} + } + build := k.Build + build.State, build.By, build.Error = sdkdelivery.BuildDone, "hand", "" + if build.Manifest == "" { + build.Manifest = t.Trunk.Manifest + } + build.Artifacts = nil + for _, a := range k.Build.Artifacts { + if _, replaced := t.Hand[a.Name]; !replaced { + build.Artifacts = append(build.Artifacts, a) + } + } + names := make([]string, 0, len(t.Hand)) + for name := range t.Hand { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + reference := t.Hand[name] + build.Artifacts = append(build.Artifacts, sdkdelivery.BuiltArtifact{Name: name, + Digest: handDigestOf.FindString(reference), Reference: reference}) + } + k.Build = build + return k +} + +// handCoversEvery refuses a registration at references that names an artifact the manifest does not declare, or leaves +// one it declares without a reference (the key's or a given one). +func handCoversEvery(m catalogue.Manifest, k builderkeys.Key, hand map[string]string) error { + declared := map[string]bool{} + if m.Build != nil { + for _, a := range m.Build.Artifacts { + declared[a.Name] = true + } + } + for name := range hand { + if !declared[name] { + return fmt.Errorf("--reference %s: %s declares no artifact of that name. Nothing was registered", name, m.Module) + } + } + have := map[string]bool{} + for _, a := range k.Build.Artifacts { + have[a.Name] = true + } + var missing []string + for name := range declared { + if !have[name] { + missing = append(missing, name) + } + } + sort.Strings(missing) + if len(missing) > 0 { + return fmt.Errorf("%s declares the artifact(s) %s and no --reference names them, nor does a key of the builder. "+ + "Nothing was registered", m.Module, strings.Join(missing, ", ")) + } + return nil +} diff --git a/cmd/mesh-controller/key_takein_integration_test.go b/cmd/mesh-controller/key_takein_integration_test.go index 0c5d0e70..dd8ee319 100644 --- a/cmd/mesh-controller/key_takein_integration_test.go +++ b/cmd/mesh-controller/key_takein_integration_test.go @@ -249,3 +249,23 @@ func TestTheSwitchsListReadsTheHeldBuildsAndTheKeys(t *testing.T) { t.Fatalf("A13: the switch's list is %+v", lines) } } + +// Issue 1902: the take-in with the lookup of the catalogue's repository NOT stubbed - the catalogue's modules are registered +// with a URL source and no seat, as every live row is - reads the list from the forge's wire alone and registers the module. +func TestATakeInFindsTheCataloguesRepositoryInTheURLForm(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + was, wasForms := formsOfSources, askGitea + formsOfSources = func(context.Context, *inventory.Inventory) *sourceForms { return aFormsOnTheForge() } + t.Cleanup(func() { formsOfSources, askGitea = was, wasForms }) + aForgeAnsweringTheCatalogue(t) + if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "catalogue-anchor", Version: "1"}, + inventory.Source{Repository: "http://anchor.internal:20000/novox/mesh-catalog.git", Path: "modules/catalogue-anchor", + Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { + t.Fatal(err) + } + keys, trunk := aKeyFor("sudo", "6", fpOf('7'), "novox/mesh-catalog", "modules/sudo", "c0ffee0123456789") + if _, _, err := takeInKey(ctx, open.inventory, keys, keyTake{Module: "sudo", Trunk: trunk, Ask: registrationAsk{Merge: trunk.Commit}}); err != nil { + t.Fatalf("a key was not taken in with the real lookup of the catalogue's repository: %v", err) + } +} diff --git a/cmd/mesh-controller/legacy_takein_integration_test.go b/cmd/mesh-controller/legacy_takein_integration_test.go new file mode 100644 index 00000000..21558826 --- /dev/null +++ b/cmd/mesh-controller/legacy_takein_integration_test.go @@ -0,0 +1,317 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path" + "slices" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// What the tests below take a build in with (R4g): the controller itself registers a module only from the builder's done +// key now (takeInKey), but a great many of these tests only need a module registered with a build recorded, in the form +// the old outcome carried, so that is kept here and nowhere else. It is the controller's take-in of a build-agent outcome +// as it was, less the checks only an outcome needed. Nothing in the controller calls any of it. + +// buildFrom turns what a builder said into what the mesh keeps. +// +// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder +// says `://@sha256:…`; the mesh records the artifact-store +// reference and composes the store's address back in where a reference is used. `against` — what +// the build stood on, the catalogue's edge — is recorded the same way, so an edge names a module's +// artifact and not the machine it was pulled from. +func buildFrom(result link.BuildResult) inventory.Build { + kept := inventory.Build{ + ID: result.ID, Repository: result.Repository, Ref: result.Ref, + Commit: result.Commit, On: result.On, Failed: result.Failed, + // **What the announcement carries, kept rather than discarded** (novox/hq 04-ISSUES/050). + // The catalogue turns the manifest into requires/provides edges and `against` into build + // edges, and it is not always listening when a build happens — on a fresh mesh it cannot + // be, for exactly the modules it needs most. Keeping them is what makes a replay able to + // rebuild the graph rather than a list of names. + Path: result.Path, + // What it was made from (novox/hq issue 280): two builds with one are one build. + SourceFingerprint: result.SourceFingerprint, + SourceInputs: result.SourceInputs, + } + // When it was asked, which is what orders it against another build of the same module + // (novox/hq 04-ISSUES/219) — not when it was heard. + if asked, ok := link.BuildAskedAt(result.ID); ok { + kept.Asked = asked + } + for _, ref := range result.Against { + kept.Against = append(kept.Against, catalogue.Recorded(ref)) + } + for _, ref := range result.Mirrored { + kept.Mirrored = append(kept.Mirrored, catalogue.Recorded(ref)) + } + for _, r := range result.Read { + kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref}) + } + // What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it. + for _, s := range result.Sources { + kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths}) + } + var announced []inventory.Artifact + for _, made := range result.Made { + announced = append(announced, inventory.Artifact{ + Name: made.Name, Kind: made.Kind, Reference: made.Reference, + }) + kept.Made = append(kept.Made, inventory.Artifact{ + Name: made.Name, Kind: made.Kind, Reference: catalogue.Recorded(made.Reference), + }) + } + kept.Manifest = recordedManifest(result.Manifest, announced) + // The module name comes from the manifest, which only exists when the build got that far. + if len(kept.Manifest) > 0 { + if list, err := judgedWith(result); err == nil { + if m, err := catalogue.ParseManifestWith(kept.Manifest, list); err == nil { + kept.Module = m.Module + } + } + } + return kept +} + +// recordedManifest is a build's manifest with the store's address taken off every reference the +// build itself made. Other references — an image a module runs from a public registry — are what +// they were, which is why this rewrites only what `made` names rather than everything that looks +// like an address. +func recordedManifest(raw json.RawMessage, made []inventory.Artifact) json.RawMessage { + announced := map[string]bool{} + for _, a := range made { + announced[a.Reference] = true + } + return withReferences(raw, func(ref string) (string, bool) { + if !announced[ref] { + return ref, false + } + return catalogue.Recorded(ref), true + }) +} + +// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and +// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's +// result reaches the catalogue whether or not the asker was still listening. +// +// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one +// nobody asked for, and the difference is the whole of whether somebody should be looking at +// something. Then parsed with the same parser a hand-written manifest goes through — a second path +// would be a second thing to disagree about what a manifest is — and registered with where it came +// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111), +// which the request carried and the outcome echoes. A definition naming an installation is refused +// here, where it would enter the catalogue; the build stays recorded and the refusal says which. +// +// Idempotent: the same outcome taken in twice registers the same module twice, which is one row +// written with the same values. +func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) ( + catalogue.Manifest, inventory.Build, error) { + // The list of predefined keys as the store keeps it now: another controller process may have kept a newer one. + if err := loadPredefined(ctx, inv); err != nil { + return catalogue.Manifest{}, inventory.Build{}, fmt.Errorf("the list of predefined keys kept cannot be "+ + "read: %w", err) + } + kept := buildFrom(result) + if err := inv.RecordBuild(ctx, kept); err != nil { + return catalogue.Manifest{}, kept, err + } + if result.Failed != "" { + // The builder's own words. Wrapping them in something about the control plane would put + // two explanations between a person and a build log. + return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s", + result.On, result.Repository, result.Failed) + } + // Judged first against the list the build was judged against: the catalogue's at the commit built (novox/hq + // ADR 0348 §7), so a module naming a key its own commit adds is read; and again below against the list the + // mesh publishes, before it is registered. + list, err := judgedWith(result) + if err != nil { + return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, + result.Repository, short(result.Commit), err) + } + manifest, err := catalogue.ParseManifestWith(kept.Manifest, list) + if err != nil { + return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w", + result.On, result.Repository, err) + } + recorded := inventory.Source{ + Repository: result.Repository, Path: result.Path, Ref: result.Ref, + BuiltFrom: result.Commit, Head: result.Commit, + // What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4). + Against: kept.Against, + // When it was asked, so an older request heard later does not replace a newer one + // (novox/hq 04-ISSUES/219). + Asked: kept.Asked, + } + if result.Source != nil && result.Source.Seat != "" { + recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat + } + // **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215): + // the commit is built and recorded as what it was built from, and the module keeps following + // what it followed before — the repository's default branch for one new to the catalogue. + if followedBranch(result.Ref) == "" && result.Ref != "" { + recorded.Ref = "" + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { + recorded.Ref = followedBranch(was.Ref) + } + } + trunk := result.Trunk + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" { + trunk = followedBranch(was.Ref) + } + if trunk == "" { + trunk = "main" + } + j := takenBuild{kept: kept, manifest: manifest, recorded: recorded, ask: registrationAsk{Build: result.ID}, path: result.Path, + trunk: trunk, by: result.On, what: result.Repository, commit: result.Commit} + // The checks only an outcome of build-agent needs: a key is a trunk build by construction (ADR 0501 §2), in no + // scratch namespace, and carries no list of its own. + j.published = func() error { + // **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's + // default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay + // --register` of one — is for checking, and is never a module's version; nothing could then send it. + // The branch the module already follows is its trunk — never the branch a build was asked at, or a + // build of a feature branch by name would make that branch its trunk. + follows := "" + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { + follows = followedBranch(was.Ref) + } + if err := publishable(result, follows); err != nil { + return err + } + return nil + } + j.carried = func() error { + // **The newest trunk build's list of predefined keys is published** (novox/hq ADR 0348 §7): a build of the + // catalogue that may be registered keeps its list when its commit is newer than the one kept. + carried, err := carriedList(result) + if err != nil { + return err + } + if carried != nil { + if err := publishPredefined(ctx, inv, result, carried); err != nil { + fmt.Printf("%s: the list of predefined keys it carried is not published: %v\n", result.ID, err) + } + } + return nil + } + manifest, err = registerJudged(ctx, inv, j) + return manifest, kept, err +} + +// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published. +var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " + + "never published (ADR 0238) — merge it, and the merge builds it") + +// publishable says whether a build's outcome may become a module's version: its commit on the module's +// trunk, as the build seat read the forge at the build — the branch the module follows when its source +// names one, else its repository's default branch. A build seat that could not say — one older than the +// rule, building its own successor — is let through and said, so the rule can reach the mesh. +func publishable(result link.BuildResult, follows string) error { + if result.DryRun { + return errors.New("a dry run is never published") + } + if result.Trunk == "" { + fmt.Printf("%s: the build seat did not say whether %s is on its repository's trunk (it predates ADR 0238); "+ + "registered as before\n", result.ID, short(result.Commit)) + return nil + } + trunk := result.Trunk + if follows != "" { + trunk = follows + } + on := slices.Contains(result.Branches, trunk) || (trunk == result.Trunk && result.OnTrunk) + if !on { + return fmt.Errorf("%w (%s is not on %s)", errOffTheTrunk, short(result.Commit), trunk) + } + return nil +} + +// fromTheCatalogue is whether a build is of the catalogue's repository, whose list alone the mesh publishes: by +// its name, in the URL form or the seat form. +func fromTheCatalogue(result link.BuildResult) bool { + for _, repository := range []string{result.Repository, sourceRepository(result)} { + name := strings.TrimSuffix(path.Base(strings.TrimRight(repository, "/")), ".git") + if name == catalogue.CatalogueRepository { + return true + } + } + return false +} + +func sourceRepository(result link.BuildResult) string { + if result.Source == nil { + return "" + } + return result.Source.Repository +} + +// carriedList is the list a build of the catalogue carried, read strictly; nil for a build of another +// repository, or one that carried none. +func carriedList(result link.BuildResult) (catalogue.PredefinedList, error) { + if result.Predefined == nil || !fromTheCatalogue(result) { + return nil, nil + } + list, err := catalogue.ParsePredefined(result.Predefined.Keys) + if err != nil { + return nil, fmt.Errorf("%s carried a list of predefined keys that is refused: %w", result.ID, err) + } + return list, nil +} + +// judgedWith is the list a build's manifest is first judged against at registration: the catalogue's own at the +// commit built, which its build judged it against, where it carried one; the list kept otherwise. +func judgedWith(result link.BuildResult) (catalogue.PredefinedList, error) { + list, err := carriedList(result) + if err != nil || list != nil { + return list, err + } + return catalogue.PublishedPredefined(), nil +} + +// publishPredefined keeps a trunk build's list when it is newer than the one kept, says so, and loads what the +// store keeps then — this build's, or a newer one another controller process kept. +func publishPredefined(ctx context.Context, inv *inventory.Inventory, result link.BuildResult, + list catalogue.PredefinedList) error { + kept, err := inv.KeepPredefined(ctx, inventory.PredefinedRecord{Keys: list, Repository: result.Repository, + Commit: result.Commit, Generation: result.Predefined.Generation, Build: result.ID}) + if err != nil { + return err + } + if kept { + fmt.Printf("%s: the list of predefined keys at %s (generation %d) is published: %s\n", result.ID, + short(result.Commit), result.Predefined.Generation, strings.Join(list.Keys(), ", ")) + } else { + fmt.Printf("%s: the list of predefined keys at %s (generation %d) is not newer than the one kept, which "+ + "stands\n", result.ID, short(result.Commit), result.Predefined.Generation) + } + return loadPredefined(ctx, inv) +} + +// builds is what the tests of a pending assignment feed an outcome to: the take-in above, then the tick that settles what +// waited for it (settlePending), as the daemon's hearing of an outcome did before R4g (it also moved the plans, which the +// walk now does from its keys). Nothing in the controller calls it. +type builds struct { + inv *inventory.Inventory + open *stores +} + +func (b builds) Built(ctx context.Context, result link.BuildResult) error { + _, _, err := takeIn(ctx, b.inv, result) + if b.open != nil { + settlePending(ctx, b.open, time.Now()) + } + if result.Failed != "" { + return nil // the build is recorded; whether it registered is what the tests read + } + return err +} diff --git a/cmd/mesh-controller/loop_own_consumers_test.go b/cmd/mesh-controller/loop_own_consumers_test.go index 6136501c..d8f5ae9f 100644 --- a/cmd/mesh-controller/loop_own_consumers_test.go +++ b/cmd/mesh-controller/loop_own_consumers_test.go @@ -34,16 +34,6 @@ func deafObservations(now time.Time, held func(broker.Consumer) (uint64, error)) return obs[0].Said, len(obs) } -// 1063/AC1: a check in flight on a builder is not the controller's event loop being deaf. -func TestDeafLookIgnoresCheckWorkers(t *testing.T) { - now := time.Now() - for _, w := range broker.CheckWorkers() { - if said, n := deafObservations(now, holdingOne(w.Stream+"/"+w.Name)); n != 0 { - t.Fatalf("%s holding one message raised controller-deaf: %s", w.Name, said) - } - } -} - // 1063/AC2: a message held by one of the controller's own consumers is still deaf, naming its stream. func TestDeafLookStillCountsOwnConsumers(t *testing.T) { now := time.Now() @@ -55,25 +45,16 @@ func TestDeafLookStillCountsOwnConsumers(t *testing.T) { } } -// 1063/AC3: the list is the controller's consumers and none of the check workers. +// 1063/AC3: the list is the controller's consumers: the mesh defines no other durable consumer the loop could read (the +// check workers went with the checks' queues, ADR 0525 clause 6). func TestDeafLookReadsExactlyTheControllersOwnConsumers(t *testing.T) { - workers := map[string]bool{} - for _, w := range broker.CheckWorkers() { - workers[w.Stream+"/"+w.Name] = true - } want := map[string]bool{} for _, c := range broker.MeshConsumers() { - if !workers[c.Stream+"/"+c.Name] { - want[c.Stream+"/"+c.Name] = true - } + want[c.Stream+"/"+c.Name] = true } got := map[string]bool{} for _, c := range loopConsumers() { - k := c.Stream + "/" + c.Name - if workers[k] { - t.Errorf("the deaf look reads the check worker %s", k) - } - got[k] = true + got[c.Stream+"/"+c.Name] = true } if len(got) != len(want) { t.Fatalf("deaf look reads %v, want %v", got, want) diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 17189ffe..37e6ceaf 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -8,7 +8,6 @@ package main import ( "context" - "errors" "flag" "fmt" "os" @@ -19,7 +18,6 @@ import ( "github.com/novox/mesh-controller/internal/identity" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/licences" - "github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/store" ) @@ -82,8 +80,6 @@ func run() error { case "build", "rebuild": // **The hand build goes with the asks** (novox/hq ADR 0501 §5): refused by name, naming the replacement. return removedByName(args[0]) - case "builder": - return builderCommand(ctx, args[1:]) case "board": return boardCommand(ctx, args[1:]) case "api": @@ -98,23 +94,10 @@ func run() error { return rehearseCommand(ctx, args[1:]) case "builds": return buildsCommand(ctx, args[1:]) - // The build queue, controlled by hand (novox/hq ADR 0219). - case "queue": - return queueCommand(ctx, args[1:]) - case "cancel": - return cancelCommand(ctx, args[1:]) - case "clear": - return clearCommand(ctx, args[1:]) - case "replay": - return replayCommand(ctx, args[1:]) - case "kill": - return killCommand(ctx, args[1:]) - case "forget-checks": - return removedByName("forget-checks") + case "forget-checks", "queue", "cancel", "clear", "kill", "replay", "pause", "resume": + return removedByName(args[0]) case "try": return tryCommand(ctx, args[1:]) - case "pause", "resume": - return pauseCommand(ctx, args[0], args[1:]) case "collection": return collectionCommand(ctx, args[1:]) // What the records say the registries may keep, asked on demand (novox/hq ADR 0251). @@ -157,12 +140,6 @@ func run() error { // The facts snapshot a merge check is fed (novox/hq to-be 45 §9). case "facts": return factsCommand(ctx, args[1:]) - // The merge gate: every machine of the snapshot composed with a change (novox/hq to-be 45 §9). - case "merge-gate": - return mergeGateCommand(ctx, args[1:]) - // A pull request's merge check run here exactly as the build seat runs it (novox/hq issue 286). - case "check-here": - return checkHereCommand(ctx, args[1:]) case "upgrade": return upgradeCommand(ctx, args[1:]) // The bus as a planned step (novox/hq to-be 45 §8, ADR 0236). @@ -313,15 +290,11 @@ var usageText = `mesh-controller — the control plane operator key set [--replace] tell the mesh which operator key to seal to operator key show the operator key, and what it can recover register / register a module from a repository the catalogue does not - build, from the builder's done key of its fingerprint (ADR 0501 §4) + build, from the builder's done key of its fingerprint (ADR 0501 §4); with + --reference = (repeatable) and --why, at artifacts the store + holds, recorded as a build by hand (ADR 0526 §3) switch [--json] the switch's list: each module, the build it runs, and what the builder holds - builds [] what has been built lately, and what came of it - queue [--json] every ask in the build queue: waiting, in flight (where, how long), dead - cancel drop a waiting or dead ask; recorded failed, cancelled by hand - clear [--dead] cancel every waiting ask (and the dead ones); never one in flight - replay that build's commit again, a dry run: what a build of it makes - kill end a build where it runs; recorded failed, killed by hand - pause [] / resume [] the build seat's holder there, or every holder, takes nothing new / again + builds [] [-n N] what has been built lately: the builder's done keys, recorded as builds plans retry ask a failed plan's failed builds again, and carry the plan on plans stop|close --why end a plan by hand; recorded in the hand-act log hand-act record --why --cause [--condition ] @@ -338,8 +311,6 @@ var usageText = `mesh-controller — the control plane durations [--kind K] [--days N] [--json] apply, heartbeat, plan-tier, build and walk-phase durations collection [--json] kept archives held/unheld by a manifest, and what the sweep may let go - builder issue a broker account for a build machine, scoped to build work, - delivered as the builder module's broker secret (module add it first) licence add|list|use|key model access, under the name a person calls it licence manager the node that holds a refreshable licence's refresh token licence refresh mint a new access token and seal it to every holder @@ -390,87 +361,6 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) { } } -// Built is the daemon hearing a build's outcome on the bus — its own asking, an announcement's, or -// a tool's that did not wait (novox/hq issue 176) — and taking it in: recorded, and the module -// registered, the same as the waiting command does. Said either way, so the daemon's log tells what -// became of a build nobody was watching. -func (b builds) Built(ctx context.Context, result link.BuildResult) error { - // **A merge check builds nothing** (novox/hq to-be 45 §9): its verdict is said, and nothing of it is - // recorded or registered. - if result.Check != nil || result.Checked != nil { - if isTry(result) { - // A branch tried before its pull request (novox/hq issue 686): its asker reads the outcome from the - // build's log. Never said as `checked`, so the forge's holder sets no status. - fmt.Printf("%s: a try of %s at %.8s, read with `try --id`: said to nobody, nothing taken in\n", result.ID, - result.Repository, result.Ref) - return nil - } - checked(ctx, result) - return nil - } - // **A dry run is looked at, never taken in** (novox/hq issue 240). On 2026-10-04 a dry run of an - // unmerged branch was heard here like any build, registered, and its definition reached a machine - // before anyone had reviewed it. - if result.DryRun { - fmt.Printf("%s: a dry run of %s on %s, not taken in\n", result.ID, result.Repository, result.Ref) - return nil - } - manifest, _, err := takeIn(ctx, b.inv, result) - // The assignments pending on this build, made or ended (novox/hq issue 325), said in the daemon's log - // after what became of the build. - if b.open != nil { - defer func() { - for _, line := range settlePendingOnBuild(ctx, b.open, result, manifest.Module, err) { - fmt.Printf("%s: %s\n", result.ID, line) - } - }() - } - // When it was asked, so a plan takes as its outcome only a build asked for it or after it - // (novox/hq 04-ISSUES/219). Zero when the id does not say. - asked, _ := link.BuildAskedAt(result.ID) - // And how long it took, asked to heard, which a build's bound will be set from (novox/hq to-be 45 - // Phase 0). Said if lost; never a reason not to take the build in. - recordBuildDuration(ctx, b.inv, result, asked) - switch { - case err != nil && result.Failed != "": - fmt.Printf("%s: %v\n", result.ID, err) - if result.Module != "" { - planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed, asked, result.ID) - } else { - planFailedBuild(ctx, b.open, result) - } - return nil - case errors.Is(err, inventory.ErrSuperseded): - // Not a failure: the module is already at what a later request built. A plan that asked - // before that later request is answered by it; one that asked after it ignores this. - fmt.Printf("%s: %v\n", result.ID, err) - planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID) - return nil - case err != nil: - fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err) - takeRefused(ctx, b.inv, result, manifest.Module, err.Error(), refusedOutcome{ - built: func(module, why string) { - planBuilt(ctx, b.open, module, result.Commit, why, asked, result.ID) - }, - failedBuild: func(failed link.BuildResult) { planFailedBuild(ctx, b.open, failed) }, - }) - return nil - } - fmt.Printf("%s: %s %s registered, built on %s from %s\n", - result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit)) - // What the walk's build did, kept on the walk whose ask it answers (novox/hq ADR 0350 §4); a build seat from - // before it says nothing, and that is what is kept. - made := result.MadeWords() - if result.Reused != "" || result.BuiltWhy != "" { - fmt.Printf("%s: %s %s\n", result.ID, manifest.Module, made) - } - saysWhenThePolicyActs(ctx, b.inv, manifest.Module) - planBuiltSaying(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID, made) - // A module registered may be one a machine is now behind: `status` is composed again. - statusFrom.nudge() - return nil -} - // verbVar carries the seat verb a command runs for, from the serving controller to the process it starts. const verbVar = "MESH_VERB" @@ -495,10 +385,24 @@ func removedByName(command string) error { "catalogue does not build is registered at the controller's terminal with `register`", "rebuild": "the controller asks no build (ADR 0501 §5): a failed key is built again by the builder as a new " + "revision (ADR 0429 §18), and a walk's Retry waits for it", - "replay --register": "a build is registered only from the builder's done key of a trunk fingerprint (ADR 0501 " + - "§1, §2); `replay` without --register still shows what a build of a recorded commit makes", + "replay": "the controller asks no build, so there is nothing to replay (ADR 0501 §5, ADR 0525 clause 6); a branch is " + + "tried with `try`", + "queue": "the controller keeps no build queue (ADR 0525 clause 6): the builds and checks are asks on the build modules' " + + "streams, and the delivery view shows their backlog and the oldest ask's age", + "cancel": "the controller keeps no build queue (ADR 0525 clause 6): an ask is on a build module's stream, and the delivery " + + "view shows the backlog", + "clear": "the controller keeps no build queue (ADR 0525 clause 6): an ask is on a build module's stream, and the delivery " + + "view shows the backlog", + "kill": "the controller keeps no build queue and starts no build (ADR 0525 clause 6): a build runs on a build module, " + + "which ends it", + "pause": "the build seat is retired (ADR 0525 clause 7): a node's builds are paused with its build module's own pause, " + + "or a capacity of 0", + "resume": "the build seat is retired (ADR 0525 clause 7): a node's builds are resumed with its build module's own " + + "resume, or its capacity", "forget-checks": "build-agent makes no check build from R4e (ADR 0503 §3), so there is nothing to let go of", "delivery-plan": "a delivery's plan is read with `delivery-order` and the checker's verdict (ADR 0503 §3)", + "delivery-check": "the checker judges every head and every delivery group (ADR 0503 §3, ADR 0527): the controller asks " + + "nothing and says no verdict of a pull request", }[command] return &removedCommand{msg: fmt.Sprintf("`%s` is removed: %s", command, replacement)} } diff --git a/cmd/mesh-controller/members_file_test.go b/cmd/mesh-controller/members_file_test.go index 23dc27b0..9c5b0309 100644 --- a/cmd/mesh-controller/members_file_test.go +++ b/cmd/mesh-controller/members_file_test.go @@ -47,18 +47,6 @@ func readBackFromTheCommand(t *testing.T, argv []string, members string) { } } -func TestDeliveryCheckHandsALongMembersListInAFile(t *testing.T) { // 1272/AC1 - members := aLongGroup(t) - argv, err := argvFor("delivery-check", map[string]any{"group": "feat/x", "members": members}) - if err != nil { - t.Fatal(err) - } - if argv[0] != "delivery" || argv[1] != "check" || !slices.Contains(argv, "--group") { - t.Errorf("the command is %.100v", strings.Join(argv, " ")) - } - readBackFromTheCommand(t, argv, members) -} - func TestDeliveryOrderHandsALongMembersListInAFile(t *testing.T) { // 1272/AC2 members := aLongGroup(t) argv, err := argvFor("delivery-order", map[string]any{"members": members}) @@ -73,8 +61,8 @@ func TestDeliveryOrderHandsALongMembersListInAFile(t *testing.T) { // 1272/AC2 func TestAShortMembersListIsStillTheArgumentItWas(t *testing.T) { // 1272/AC3 members := `[{"id":"novox/a#1"},{"id":"novox/b#2"}]` - argv, err := argvFor("delivery-check", map[string]any{"group": "feat/x", "members": members, "ready": "true"}) - want := []string{"delivery", "check", "--group", "feat/x", "--members", members, "--ready"} + argv, err := argvFor("delivery-order", map[string]any{"members": members}) + want := []string{"delivery", "order", "--members", members} if err != nil || !slices.Equal(argv, want) { t.Errorf("a short list gives %v, %v, want %v", argv, err, want) } diff --git a/cmd/mesh-controller/merge_check_nested_test.go b/cmd/mesh-controller/merge_check_nested_test.go deleted file mode 100644 index 7dd78da6..00000000 --- a/cmd/mesh-controller/merge_check_nested_test.go +++ /dev/null @@ -1,72 +0,0 @@ -//go:build integration - -package main - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// **A repository's merge check runs every module's unit tests, a Go module of its own below the root included** -// (novox/hq ADR 0420 §5, issue 1025): `./...` stops at a nested go.mod. This runs the script -// check-nested-modules.sh in a tree with a `go` that only records where it was run and fails for a module named bad. -func TestTheMergeCheckVetsAndTestsEveryNestedGoModule(t *testing.T) { - script, err := filepath.Abs("../../check-nested-modules.sh") - if err != nil { - t.Fatal(err) - } - bin, tree := t.TempDir(), t.TempDir() - log := filepath.Join(t.TempDir(), "go.log") - fake := "#!/bin/sh\necho \"$(basename \"$PWD\") $1 GOFLAGS=[$GOFLAGS]\" >>\"$LOG\"\n" + - "[ \"$(basename \"$PWD\")\" = bad ] && [ \"$1\" = test ] && exit 1\nexit 0\n" - if err := os.WriteFile(filepath.Join(bin, "go"), []byte(fake), 0o755); err != nil { - t.Fatal(err) - } - run := func() (string, error) { - cmd := exec.Command("sh", script) - cmd.Dir = tree - cmd.Env = append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "LOG="+log, "GOFLAGS=-mod=vendor") - out, err := cmd.CombinedOutput() - return string(out), err - } - // No modules directory: a pass, nothing run. - if out, err := run(); err != nil { - t.Fatalf("a repository with no nested module failed: %v\n%s", err, out) - } - if _, err := os.Stat(log); err == nil { - t.Fatal("go ran with no nested module") - } - for _, m := range []string{"good", "bad", "other"} { - dir := filepath.Join(tree, "modules", m) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n"), 0o644); err != nil { - t.Fatal(err) - } - } - // A directory with no go.mod is no Go module of its own. - if err := os.MkdirAll(filepath.Join(tree, "modules", "typescript"), 0o755); err != nil { - t.Fatal(err) - } - out, err := run() - if err == nil { - t.Fatalf("a nested module whose tests fail passed the check:\n%s", out) - } - raw, _ := os.ReadFile(log) - ran := string(raw) - for _, want := range []string{"good vet GOFLAGS=[]", "good test GOFLAGS=[]", "other test GOFLAGS=[]", "bad test GOFLAGS=[]"} { - if !strings.Contains(ran, want) { - t.Errorf("%q was not run, nor outside the vendor directory:\n%s", want, ran) - } - } - if strings.Contains(ran, "typescript") { - t.Errorf("a directory with no go.mod was run:\n%s", ran) - } - if !strings.Contains(out, "nested module modules/bad FAILED") { - t.Errorf("the failing module is not named:\n%s", out) - } -} diff --git a/cmd/mesh-controller/merge_gate.go b/cmd/mesh-controller/merge_gate.go deleted file mode 100644 index 35343ec3..00000000 --- a/cmd/mesh-controller/merge_gate.go +++ /dev/null @@ -1,1580 +0,0 @@ -package main - -import ( - "context" - "crypto/ecdh" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "io/fs" - "os" - "path/filepath" - "slices" - "sort" - "strings" - "time" - - "github.com/jackc/pgx/v5" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/artifacts" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" - "github.com/novox/mesh-controller/internal/store" -) - -// The merge gate (novox/hq to-be 45 §9, ADR 0227 rule 9): **every machine of the mesh composed with the -// change, and the node-engine's own validator run over each, before the change merges.** -// -// A change is judged against the mesh as the facts snapshot says it runs, twice: once as it is (the -// base) and once with the change applied, each in a throwaway store raised from the snapshot through -// the controller's own code — the same registration, assignment, seats, pins and settings a running -// controller keeps, and the same composition a push makes. What the change breaks is what composes or -// validates in the base and not with the change, named by the machine's role and the module; what was -// already broken is said and does not fail the change. -// -// What it catches, by the incidents it was written from: -// - a manifest the node-engine refuses whole, found only by assigning it to a live machine (236); -// - an identity a real machine's name makes too long for a provision, which refused the provider's -// whole machine (263) — or, since ADR 0225, leaves the consumer out of its grants: either fails; -// - a manifest the controller the mesh runs cannot read (version skew): a catalogue change is judged -// by that controller, so a field only a newer one knows fails here, not at registration; -// - a module removed from its source while machines still run it (ADR 0236), and two definitions of -// one module name (239); -// - a setting the mesh holds that the changed definition cannot compose with (ADR 0163); -// - and, said rather than failed, how wide the rebuild of the merge would be (278). -// -// It runs where a build runs: the build seat, asked to check a pull request (checks.go), so no CI -// outside the mesh is needed; and by hand, with a snapshot and a throwaway store, by anyone. - -// mergeCheckInput is what one judging needs. -type mergeCheckInput struct { - facts snapshot.Facts - // repository is the change's owner/repository; tree its checkout, empty when the change is to code - // alone and every module stays as the mesh holds it. - repository, tree string - // changed are the paths the change touches, for the width of its rebuild. - changed []string - // group are the other heads of a delivery group composed with this one (novox/hq ADR 0239), each laid - // over the mesh after the one before: the group judged as one future state. - group []groupChange - // held are the approved config values held for the keys the pull requests introduce (novox/hq ADR 0502 clause 4), - // laid over the composition with the change only. - held []heldValue - // admin is a PostgreSQL the gate may create and drop databases in. - admin string - // beside is the directory holding the core clones beside the check (MESH_CHECK_BESIDE), for finding moves. - beside string - // say is where progress goes; the verdict is returned. - say io.Writer -} - -// groupChange is one other head of a delivery group: its repository, its checkout and what it changes. -type groupChange struct { - Repository string `json:"repository"` - Tree string `json:"tree"` - Changed []string `json:"changed,omitempty"` -} - -// readGroup reads the other heads of a group's composed check from the file the build seat wrote. -func readGroup(path string) ([]groupChange, error) { - if path == "" { - return nil, nil - } - raw, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var g []groupChange - if err := json.Unmarshal(raw, &g); err != nil { - return nil, fmt.Errorf("the group's heads are not readable: %w", err) - } - for _, c := range g { - if c.Repository == "" || c.Tree == "" { - return nil, errors.New("a head of the group names no repository or no checkout") - } - } - return g, nil -} - -// mergeVerdict is what the gate found. -type mergeVerdict struct { - Verdict string `json:"verdict"` // pass, warning or fail - Summary string `json:"summary"` - // Judge is the controller build that judged it, and Facts when the snapshot it judged against was taken. - Judge string `json:"judge"` - Facts time.Time `json:"facts"` - Failures []string `json:"failures,omitempty"` - // Errors are what kept the gate from judging: a machine the mesh composes that the gate could not - // raise as it is. Any one makes the verdict an error — never a pass (novox/hq issue 285). - Errors []string `json:"errors,omitempty"` - Warnings []string `json:"warnings,omitempty"` - Notes []string `json:"notes,omitempty"` - Machines []mergeMachine `json:"machines"` - Modules map[string]string `json:"modules,omitempty"` // changed module → what changed: added, changed, removed - Width *mergeWidthOf `json:"width,omitempty"` -} - -// mergeMachine is one machine, composed as it is and with the change. -type mergeMachine struct { - Name string `json:"name"` - Described string `json:"described"` - Live bool `json:"live-composes"` - Base mergeComposed `json:"base"` - Change mergeComposed `json:"change"` - Added []string `json:"added,omitempty"` - Removed []string `json:"removed,omitempty"` - Modules map[string]int `json:"-"` -} - -// mergeComposed is one machine's composition in one store. -type mergeComposed struct { - Composes bool `json:"composes"` - Problems []string `json:"problems,omitempty"` - Withheld []string `json:"withheld,omitempty"` - Unbound []string `json:"unbound,omitempty"` - Unplaced []string `json:"unplaced,omitempty"` - LeftOut map[string]string `json:"left-out,omitempty"` - Resources []string `json:"resources,omitempty"` -} - -// mergeWidthOf is how much a merge of the change would rebuild. -type mergeWidthOf struct { - Modules []string `json:"modules"` - Tiers int `json:"tiers"` - // Unread is the changed paths no module's build reads — in no module's directory — which rebuild - // nothing (issue 280): said, so a reader sees why a change to them moves no module. - Unread []string `json:"unread,omitempty"` -} - -// wideRebuild is how many modules a rebuild may take before the gate says so as a warning. -var wideRebuild = 12 - -// changedPaths are the paths --changed (comma-separated) and --changed-file (one per line) name together. -func changedPaths(list, file string) ([]string, error) { - out := splitList(list) - if file == "" { - return out, nil - } - raw, err := os.ReadFile(file) - if err != nil { - return nil, fmt.Errorf("--changed-file: %w", err) - } - for _, line := range strings.Split(string(raw), "\n") { - if line = strings.TrimSpace(line); line != "" { - out = append(out, line) - } - } - return out, nil -} - -// mergeGateCommand is `gate`: -// -// merge-gate --facts --store [--repository owner/repo --tree ] [--changed a,b] [--json] -// -// --facts may be a file or `store`, the snapshot the artifact store holds (MESH_REGISTRY names it). -// --store is a PostgreSQL the gate may create and drop databases in: a throwaway, never the mesh's. -func mergeGateCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("merge-gate", flag.ContinueOnError) - factsFrom := set.String("facts", "", "the facts snapshot: a file, or `store` for the one the artifact store holds") - admin := set.String("store", os.Getenv("MESH_GATE_POSTGRES"), "a throwaway PostgreSQL the gate may create databases in") - repository := set.String("repository", "", "owner/repository of the change") - tree := set.String("tree", "", "the change's checkout: every module.json in it replaces the mesh's") - changed := set.String("changed", "", "the paths the change touches, comma-separated, for its rebuild's width") - changedFile := set.String("changed-file", "", "a file of the paths the change touches, one per line; the same as "+ - "--changed, for a list too long for an argument (novox/hq issue 1247)") - group := set.String("group", "", "a delivery group's other heads, as JSON [{repository, tree, changed}], composed "+ - "with this one as one future state (novox/hq ADR 0239)") - heldFile := set.String("held", "", "a file of the config values approved for keys the change's pull requests introduce, as JSON "+ - "[{pull, node, module, key, value, approved_by}], laid over the composition with the change (novox/hq ADR 0502)") - asJSON := set.Bool("json", false, "print the verdict as JSON") - if _, err := parseAround(set, args); err != nil { - return err - } - if *factsFrom == "" || *admin == "" { - return errors.New("merge-gate --facts --store [--repository owner/repo --tree ] " + - "[--changed paths] [--json]") - } - if (*tree == "") != (*repository == "") && *tree != "" { - return errors.New("--tree names the change's checkout; --repository says which repository it is") - } - f, err := readFacts(ctx, *factsFrom) - if err != nil { - return err - } - others, err := readGroup(*group) - if err != nil { - return err - } - held, err := readHeld(*heldFile) - if err != nil { - return err - } - out := io.Writer(os.Stdout) - stdout := os.Stdout - if *asJSON { - out = os.Stderr - // **The verdict is the only thing on standard output** when it is asked for as JSON: composition - // says what it finds as it goes (a bus user without a credential, a module left out) and prints it, - // and a line of that before the verdict makes the verdict unreadable — the build seat would read no - // verdict at all. What is said goes beside the report. - os.Stdout = os.Stderr - } - changedList, err := changedPaths(*changed, *changedFile) - if err != nil { - return err - } - v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: changedList, - group: others, held: held, admin: *admin, say: out, beside: os.Getenv("MESH_CHECK_BESIDE")}) - os.Stdout = stdout - if err != nil { - return err - } - if *asJSON { - body, err := json.MarshalIndent(v, "", " ") - if err != nil { - return err - } - fmt.Println(string(body)) - // And the report a person reads, beside it: what the pull request is told comes from here. - fmt.Fprint(os.Stderr, v.Report()) - } else { - fmt.Print(v.Report()) - } - switch v.Verdict { - case "fail": - return errMergeGateFailed - case "error": - return errMergeGateCouldNotJudge - } - return nil -} - -// errMergeGateCouldNotJudge is the gate's own error: the mesh as it is could not be raised, so nothing -// the change does to it can be seen. Never a pass (novox/hq issue 285). -var errMergeGateCouldNotJudge = errors.New("the merge gate could not judge the change") - -// errMergeGateFailed is the gate's own failure: the verdict says why, so the error says nothing more. -var errMergeGateFailed = errors.New("the change fails the merge gate") - -// readFacts reads a snapshot from a file, or from the artifact store. -func readFacts(ctx context.Context, from string) (snapshot.Facts, error) { - var body []byte - var err error - if from == "store" { - address := strings.TrimSpace(os.Getenv("MESH_REGISTRY")) - if address == "" { - return snapshot.Facts{}, errors.New("--facts store reads the artifact store MESH_REGISTRY names, and it names none") - } - body, _, err = (artifacts.Store{Address: address}).GetTagged(ctx, snapshot.Repository, snapshot.Tag) - } else { - body, err = os.ReadFile(from) - } - if err != nil { - return snapshot.Facts{}, err - } - return snapshot.Decode(body) -} - -// judgeChange is the gate: both compositions, and what the change did to each machine. -func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error) { - v := mergeVerdict{Judge: version, Facts: in.facts.Taken, Modules: map[string]string{}} - say := func(format string, args ...any) { - if in.say != nil { - fmt.Fprintf(in.say, format+"\n", args...) - } - } - base, err := shelfOfFacts(in.facts) - if err != nil { - return v, err - } - change := base - sources := sourcesOfFacts(in.facts) - // **What the change moves is the planner's answer** (reachOfMerge, novox/hq ADR 0238), asked of the - // snapshot as if the change were merged: the definitions composed with the change are those of the - // modules a merge of it would rebuild, and of the modules it adds — not every definition in the tree, - // which may differ from what the mesh holds for reasons that are not this change's (a module pinned - // at an older commit, a main the mesh has not built yet). - var reach *mergeReach - // The change, then each other head of its delivery group laid over it in turn (novox/hq ADR 0239): one - // future state of the mesh, judged as the group would leave it. - heads := append([]groupChange{{Repository: in.repository, Tree: in.tree, Changed: in.changed}}, in.group...) - // **A module one head removes and another defines is a move** (novox/hq ADR 0420 §6, issue 1025): neither - // the removal of a module machines run (ADR 0236) nor a second definition of one module name (issue 239). - // The change is judged with the module built from the repository that now defines it. - moves, err := movesOfChange(in, sources) - if err != nil { - return v, err - } - changeSources := sources - if len(moves) > 0 { - changeSources = make(map[string]inventory.Source, len(sources)) - for k, o := range sources { - changeSources[k] = o - } - for name, mv := range moves { - o := changeSources[name] - o.Repository, o.Path = mv.To, mv.Dir - changeSources[name] = o - } - } - for i, h := range heads { - var r *mergeReach - if h.Repository != "" && len(h.Changed) > 0 { - got, err := reachOfChange(in.facts, h.Repository, h.Changed, h.Tree) - if err != nil { - v.Notes = append(v.Notes, "what a merge of "+h.Repository+" would rebuild could not be worked out, so "+ - "every definition in its tree is judged: "+err.Error()) - } else { - r = &got - } - } - if i == 0 { - reach = r - } - if h.Tree == "" { - continue - } - var failures []string - scope := scopeOfReach(r, h.Repository) - if scope != nil { - for _, mv := range moves { - if mv.To == h.Repository { - scope[mv.Dir] = true - } - } - } - change, failures, err = shelfWithChange(change, changeSources, in.facts, h.Repository, h.Tree, v.Modules, scope) - if err != nil { - return v, err - } - v.Failures = append(v.Failures, failures...) - } - for name := range moves { - v.Modules[name] = "moved" - } - if len(in.group) > 0 { - say("a delivery group: %d head(s) composed together", len(heads)) - } - say("judging %d module(s) changed against %d machine(s), as the snapshot of %s says they run", - len(v.Modules), len(in.facts.Machines), in.facts.Taken.Format(time.RFC3339)) - - // What no single machine shows: the rules between manifests, identities on the mesh's own longest - // name, and the data each module keeps. Only what the change adds is the change's. - for _, rule := range []func(catalogue.Shelf) []string{ - catalogue.CatalogueProblems, - func(s catalogue.Shelf) []string { return catalogue.IdentityProblems(s, in.facts.Longest()) }, - catalogue.DataProblems, - } { - was := rule(catalogue.Shelf(base)) - for _, p := range rule(catalogue.Shelf(change)) { - if !slices.Contains(was, p) { - v.Failures = append(v.Failures, p) - } - } - } - - // Both compositions, each in a store of its own. - say("composing every machine as the mesh is") - before, _, notes, err := composeEveryMachine(ctx, in, base, sources, nil) - if err != nil { - return v, fmt.Errorf("the mesh as the snapshot says it is could not be raised: %w", err) - } - v.Notes = append(v.Notes, notes...) - say("composing every machine with the change") - var added []string - for module, how := range v.Modules { - if how == "added" { - added = append(added, module) - } - } - sort.Strings(added) - // **The held values are laid in this composition only** (novox/hq ADR 0502 clause 4): the mesh as it is does not - // declare their keys, so the base composition would refuse them. - withHeld := in - var heldNotes []string - withHeld.facts, heldNotes = layHeld(in.facts, in.held, change) - for _, n := range heldNotes { - say("%s", n) - } - v.Notes = append(v.Notes, heldNotes...) - after, tried, notes, err := composeEveryMachine(ctx, withHeld, change, changeSources, added) - if err != nil { - return v, fmt.Errorf("the mesh with the change could not be raised: %w", err) - } - for _, n := range notes { - if !slices.Contains(v.Notes, n) { - v.Failures = append(v.Failures, n) - } - } - - for _, m := range in.facts.Machines { - gm := mergeMachine{Name: m.Name, Described: m.Described(), Live: m.Declaration.Composes, - Base: before[m.Name], Change: after[m.Name]} - gm.Added, gm.Removed = difference(gm.Base.Resources, gm.Change.Resources) - v.Machines = append(v.Machines, gm) - switch { - case gm.Base.Composes && !gm.Change.Composes: - v.Failures = append(v.Failures, fmt.Sprintf("%s: nothing could be sent to it with this change — %s", - gm.Described, firstOr(newOnly(gm.Change.Problems, gm.Base.Problems), gm.Change.Problems))) - case !gm.Base.Composes && m.Declaration.Composes: - // **The mesh composes it and the gate could not raise it as it is** (novox/hq issue 285): a fact - // the snapshot does not carry, or one the gate does not raise. Then the change is judged against a - // machine that is not the mesh's — broken against broken, which passes whatever the change does — - // so the gate cannot judge, and says so: an error, never a pass. - v.Errors = append(v.Errors, fmt.Sprintf("%s composes on the mesh and not as the gate raised it from the "+ - "snapshot: %s", gm.Described, firstOr(gm.Base.Problems, nil))) - if added := newOnly(gm.Change.Problems, gm.Base.Problems); len(added) > 0 { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change adds — %s", gm.Described, added[0])) - } - case !gm.Base.Composes: - v.Notes = append(v.Notes, fmt.Sprintf("%s does not compose on the mesh today either: %s", - gm.Described, firstOr(m.Declaration.Problems, gm.Base.Problems))) - } - for _, w := range newOnly(gm.Change.Withheld, gm.Base.Withheld) { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a consumer out of its grants — %s", - gm.Described, w)) - } - for _, u := range newOnly(gm.Change.Unbound, gm.Base.Unbound) { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a credential bound elsewhere — %s", - gm.Described, u)) - } - // A contribution its holder's template renders nothing for (novox/hq ADR 0255): the machine - // would be sent without it, so a change that adds one is refused, naming it. - for _, u := range newOnly(gm.Change.Unplaced, gm.Base.Unplaced) { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a contribution unplaced — %s", - gm.Described, u)) - } - for module, why := range gm.Change.LeftOut { - if _, was := gm.Base.LeftOut[module]; !was { - v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves %s out of its declaration — %s", - gm.Described, module, why)) - } - } - } - - // A module nobody runs yet, tried on a machine that could run it. - for _, module := range added { - t := tried[module] - for _, r := range t.Refused { - v.Failures = append(v.Failures, fmt.Sprintf("%s, assigned to %s, would be refused by its node-engine — %s", - module, t.Machine, r)) - } - if t.Said != "" { - v.Notes = append(v.Notes, fmt.Sprintf("%s could not be tried on a machine: %s", module, t.Said)) - } - } - - if reach != nil { - w := widthOf(*reach) - v.Width = &w - if len(w.Unread) > 0 { - v.Notes = append(v.Notes, fmt.Sprintf("%s read by no module's build, and rebuild nothing (issue 280)", - readableList(w.Unread))) - } - if len(w.Modules) > wideRebuild { - v.Warnings = append(v.Warnings, fmt.Sprintf("a merge rebuilds %d module(s) in %d tier(s)", - len(w.Modules), w.Tiers)) - } - } - - sort.Strings(v.Failures) - v.Failures = slices.Compact(v.Failures) - sort.Strings(v.Errors) - switch { - case len(v.Failures) > 0: - v.Verdict = "fail" - v.Summary = fmt.Sprintf("%d problem(s) the change brings; the first: %s", len(v.Failures), v.Failures[0]) - case len(v.Errors) > 0: - v.Verdict = "error" - v.Summary = fmt.Sprintf("the mesh as it is could not be raised, so the change cannot be judged against it "+ - "(%d of %d machines compose on the mesh and not in the gate); the first: %s", len(v.Errors), - len(v.Machines), v.Errors[0]) - case len(v.Warnings) > 0: - v.Verdict = "warning" - v.Summary = v.Warnings[0] - default: - v.Verdict = "pass" - composing := 0 - for _, m := range v.Machines { - if m.Change.Composes { - composing++ - } - } - v.Summary = fmt.Sprintf("every machine composes with the change as it did without (%d of %d compose)", - composing, len(v.Machines)) - } - return v, nil -} - -// Report is the verdict as a person reads it, and as a pull request is told it. -func (v mergeVerdict) Report() string { - var b strings.Builder - fmt.Fprintf(&b, "merge gate: %s — %s\n", strings.ToUpper(v.Verdict), v.Summary) - fmt.Fprintf(&b, "judged by controller %s against the facts of %s\n", v.Judge, v.Facts.Format(time.RFC3339)) - list := func(title string, items []string) { - if len(items) == 0 { - return - } - fmt.Fprintf(&b, "\n%s:\n", title) - for _, i := range items { - fmt.Fprintf(&b, " - %s\n", i) - } - } - list("fails", v.Failures) - list("could not judge", v.Errors) - list("warns", v.Warnings) - if len(v.Modules) > 0 { - var changed []string - for m, how := range v.Modules { - changed = append(changed, m+" ("+how+")") - } - sort.Strings(changed) - list("modules the change moves", changed) - } - var machines []string - for _, m := range v.Machines { - line := m.Described + ": " - switch { - case m.Change.Composes: - line += "composes" - default: - line += "does not compose" - } - if len(m.Added) > 0 || len(m.Removed) > 0 { - line += fmt.Sprintf("; the change adds %d resource(s) and removes %d", len(m.Added), len(m.Removed)) - if len(m.Added)+len(m.Removed) <= 6 { - line += " (" + strings.Join(append(prefixed("+", m.Added), prefixed("-", m.Removed)...), ", ") + ")" - } - } - machines = append(machines, line) - } - list("machines", machines) - if v.Width != nil { - fmt.Fprintf(&b, "\na merge would rebuild %d module(s) in %d tier(s)", len(v.Width.Modules), v.Width.Tiers) - if len(v.Width.Modules) > 0 && len(v.Width.Modules) <= wideRebuild { - fmt.Fprintf(&b, ": %s", strings.Join(v.Width.Modules, ", ")) - } - fmt.Fprintln(&b) - } - list("notes", v.Notes) - return b.String() -} - -func prefixed(p string, items []string) []string { - out := make([]string, len(items)) - for i, s := range items { - out[i] = p + s - } - return out -} - -// shelfOfFacts is every module of the snapshot, as the mesh holds it — but the ones that came with the -// controller: those are this controller's own, provided when its store is raised. -func shelfOfFacts(f snapshot.Facts) (map[string]catalogue.Manifest, error) { - out := map[string]catalogue.Manifest{} - for _, m := range f.Modules { - if m.Provided { - continue - } - var manifest catalogue.Manifest - if err := json.Unmarshal(m.Manifest, &manifest); err != nil { - return nil, fmt.Errorf("the snapshot's %s is not a manifest: %w", m.Name, err) - } - out[m.Name] = manifest - } - return out, nil -} - -// sourcesOfFacts is where each module of the snapshot is built from. -func sourcesOfFacts(f snapshot.Facts) map[string]inventory.Source { - out := map[string]inventory.Source{} - for _, m := range f.Modules { - if !m.Provided { - out[m.Name] = inventory.Source{Repository: m.Repository, Path: m.Path, BuiltFrom: m.Commit} - } - } - return out -} - -// movesOfChange is the modules the change moves: with a delivery group, those the group's heads move; without -// one, those it moves with the core clones beside the check (MESH_CHECK_BESIDE, mesh-catalog and -// mesh-controller, the repository a module can move between), so a member judged alone sees the move its -// group's composed check sees (issue 1043). The clones beside are used to find moves only, never as a -// composition of what machines get. -func movesOfChange(in mergeCheckInput, sources map[string]inventory.Source) (map[string]moveOfModule, error) { - heads := append([]groupChange{{Repository: in.repository, Tree: in.tree, Changed: in.changed}}, in.group...) - if len(in.group) == 0 && in.beside != "" && in.repository != "" && in.tree != "" { - for _, name := range []string{"mesh-catalog", "mesh-controller"} { - dir := filepath.Join(in.beside, name) - if repoOf(in.repository) == name { - continue - } - if st, err := os.Stat(dir); err != nil || !st.IsDir() { - continue - } - heads = append(heads, groupChange{Repository: ownerOf(in.repository) + "/" + name, Tree: dir}) - } - } - return movesInGroup(heads, sources) -} - -// moveOfModule is a module a delivery group moves: the repository the mesh builds it from, the one whose -// head now defines it, and its directory there. -type moveOfModule struct{ From, To, Dir string } - -// movesInGroup is the modules one head of a group removes and another defines (novox/hq ADR 0420 §6): a name -// the mesh builds from a head's repository that the head's tree no longer defines, and some other head's tree -// defines. A name removed by a head with no other head defining it, or defined by a head the mesh builds from -// another repository with none removing it, is no move. -func movesInGroup(heads []groupChange, sources map[string]inventory.Source) (map[string]moveOfModule, error) { - if len(heads) < 2 { - return nil, nil - } - defined := make([]map[string]string, len(heads)) - for i, h := range heads { - if h.Tree == "" { - continue - } - d, err := namesDefinedIn(h.Tree) - if err != nil { - return nil, err - } - defined[i] = d - } - var moves map[string]moveOfModule - for name, s := range sources { - if s.Repository == "" { - continue - } - for i, h := range heads { - if h.Tree == "" || !sameRepository(s.Repository, link.SourceMoved{Owner: ownerOf(h.Repository), Repo: repoOf(h.Repository)}) { - continue - } - if _, still := defined[i][name]; still { - break - } - // Removed by head i: defined by another? - for j, o := range heads { - if dir, ok := defined[j][name]; ok && j != i && o.Repository != h.Repository { - if moves == nil { - moves = map[string]moveOfModule{} - } - moves[name] = moveOfModule{From: h.Repository, To: o.Repository, Dir: dir} - } - } - break - } - } - return moves, nil -} - -// namesDefinedIn is the module names a tree's module.json files define, each with its directory; a manifest -// that does not read is left to shelfWithChange to refuse. -func namesDefinedIn(tree string) (map[string]string, error) { - found := map[string]string{} - err := filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - if path != tree && (ignoredInTree[d.Name()] || strings.HasPrefix(d.Name(), ".")) { - return filepath.SkipDir - } - return nil - } - if d.Name() != "module.json" { - return nil - } - raw, err := os.ReadFile(path) - if err != nil { - return err - } - var m struct { - Module string `json:"module"` - } - if json.Unmarshal(raw, &m) != nil || m.Module == "" { - return nil - } - dir, err := filepath.Rel(tree, filepath.Dir(path)) - if err != nil { - return err - } - if dir == "." { - dir = "" - } - if _, twice := found[m.Module]; !twice { - found[m.Module] = dir - } - return nil - }) - return found, err -} - -// ignoredInTree are directories a module.json in is not a module of the repository. -var ignoredInTree = map[string]bool{".git": true, "vendor": true, "node_modules": true, "testdata": true, - "examples": true, "fixtures": true} - -// shelfWithChange is the base shelf with every module of the change's tree in place of the mesh's: each -// read by this controller's strict parser — the one the mesh runs, for a change to a catalogue — resolved -// with stand-in builds, and read again as registration reads a built one. A module the repository held -// and the tree no longer has is removed. Answers the shelf and what the tree itself fails. -// -// `scope` is the directories of the modules a merge of the change would rebuild or add, as the planner says -// (scopeOfReach); a definition elsewhere in the tree is left as the mesh holds it. Nil judges every one. -func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inventory.Source, f snapshot.Facts, - repository, tree string, moved map[string]string, scope map[string]bool) (map[string]catalogue.Manifest, []string, error) { - inScope := func(dir string) bool { return scope == nil || scope[dir] } - out := make(map[string]catalogue.Manifest, len(base)) - for k, m := range base { - out[k] = m - } - var failures []string - // The list of predefined keys at the tree's root (novox/hq ADR 0348 §7): what the build of this tree judges each - // manifest against and registration publishes, so what the gate judges each against too. A list that is - // wrong is refused by name, and every manifest is then judged against none. - predefined, err := catalogue.ReadPredefined(tree) - if err != nil { - failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s", - catalogue.PredefinedFile, version, oneLine(err.Error()))) - predefined = nil - } - found := map[string]string{} // module → its directory in the tree - err = filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - if path != tree && (ignoredInTree[d.Name()] || strings.HasPrefix(d.Name(), ".")) { - return filepath.SkipDir - } - return nil - } - if d.Name() != "module.json" { - return nil - } - dir, err := filepath.Rel(tree, filepath.Dir(path)) - if err != nil { - return err - } - if dir == "." { - dir = "" - } - raw, err := os.ReadFile(path) - if err != nil { - return err - } - m, err := catalogue.ParseManifestWith(raw, predefined) - // The manifest as written, still naming its templates: what the stand-in build resolves, as a real build - // does, so a moved module is handed its templates (issue 876). - written := m - var texts map[string]string - if err == nil { - // Each template's text from the tree beside the manifest, as the build reads it back from the archive it - // packs (novox/hq ADR 0349 §2): so a change to a template alone is a change to its module. - m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(path)) - } - if err == nil && written.Templated() { - texts, err = catalogue.ReadTemplates(written, filepath.Dir(path)) - } - if err != nil { - if inScope(dir) { - failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s", - orRoot(dir), version, oneLine(err.Error()))) - } - return nil - } - if was, twice := found[m.Module]; twice { - if inScope(dir) || inScope(was) { - failures = append(failures, fmt.Sprintf("%s and %s both define %s: two definitions of one module", - orRoot(was), orRoot(dir), m.Module)) - } - return nil - } - found[m.Module] = dir - if !inScope(dir) { - return nil - } - if s, held := sources[m.Module]; held && s.Repository != "" && !sameRepository(s.Repository, - link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}) { - failures = append(failures, fmt.Sprintf("%s defines %s, which the mesh builds from %s: two definitions "+ - "of one module name (issue 239)", orRoot(dir), m.Module, s.Repository)) - return nil - } - // A module lives whole in one repository (novox/hq ADR 0420 §1, issue 1019): no artifact builds from another. - if m.Build != nil { - own := link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)} - other := false - for _, a := range m.Build.Artifacts { - if a.Context != nil && a.Context.Repository != "" && !sameRepository(a.Context.Repository, own) { - failures = append(failures, fmt.Sprintf("%s builds artifact %s from %s: a module lives whole in "+ - "one repository (novox/hq ADR 0420 §1)", m.Module, a.Name, a.Context.Repository)) - other = true - } - } - if other { - return nil - } - } - resolved, err := standInBuild(written, texts) - if err == nil { - // As registration reads a built manifest: strictly, again (takeIn). - var raw []byte - if raw, err = json.Marshal(resolved); err == nil { - resolved, err = catalogue.ParseManifestWith(raw, predefined) - } - } - if err == nil { - err = namesNoInstallation(resolved) - } - if err != nil { - failures = append(failures, fmt.Sprintf("%s: %s is not registrable — %s", orRoot(dir), m.Module, - oneLine(err.Error()))) - return nil - } - if was, held := base[m.Module]; !held { - moved[m.Module] = "added" - } else if builtAlike(was) == builtAlike(resolved) { - // Unchanged but for what a build pins: the mesh's own build stands, so the machines that - // run it compose exactly as they do. - return nil - } else { - moved[m.Module] = "changed" - } - out[m.Module] = resolved - return nil - }) - if err != nil { - return nil, nil, err - } - // What the repository held and the tree no longer defines is removed — refused while a machine runs it. - running := map[string][]string{} - for _, mc := range f.Machines { - for _, a := range mc.Assigned { - running[a] = append(running[a], mc.Described()) - } - } - for name, s := range sources { - if s.Repository == "" || !sameRepository(s.Repository, link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}) { - continue - } - if _, still := found[name]; still { - continue - } - if !inScope(strings.Trim(s.Path, "/")) { - continue - } - delete(out, name) - moved[name] = "removed" - if on := running[name]; len(on) > 0 { - failures = append(failures, fmt.Sprintf("%s is removed from %s, and %s run(s) it: unassign it first (ADR 0236)", - name, repository, readableList(on))) - } - } - return out, failures, nil -} - -func orRoot(dir string) string { - if dir == "" { - return "the repository's root" - } - return dir -} - -func ownerOf(repository string) string { - owner, _, _ := strings.Cut(repository, "/") - return owner -} - -func repoOf(repository string) string { - _, repo, _ := strings.Cut(repository, "/") - return repo -} - -// builtAlike is a resolved manifest with what a build pins taken out — the digests and the references -// they are reached by, and the version a path is named for — so a definition the change leaves alone reads -// the same as the build of it the mesh holds. -func builtAlike(m catalogue.Manifest) string { - raw, err := json.Marshal(m) - if err != nil { - return "" - } - var doc any - if err := json.Unmarshal(raw, &doc); err != nil { - return "" - } - var walk func(any) any - walk = func(v any) any { - switch t := v.(type) { - case map[string]any: - for k, e := range t { - switch strings.ToLower(k) { - case "image", "source", "digest", "launchers": - t[k] = "pinned" - default: - t[k] = walk(e) - } - } - return t - case []any: - for i, e := range t { - t[i] = walk(e) - } - return t - case string: - if before, _, found := strings.Cut(t, "/versions/"); found { - return before + "/versions/pinned" - } - return t - } - return v - } - out, err := json.Marshal(walk(doc)) - if err != nil { - return "" - } - return string(out) -} - -// standInBuild resolves a manifest as a build would, with artifacts nobody built: a digest made from the -// module's and the artifact's names, in the store's own vocabulary. Composition and validation read the -// shape of a reference, never its bytes. -func standInBuild(m catalogue.Manifest, texts map[string]string) (catalogue.Manifest, error) { - if m.Build == nil { - return m.Resolve(nil) - } - // The templates archive carries each template's text by its path inside the archive, as a build reads it back. - archive, inside := catalogue.TemplatesArtifact(m), m.TemplatesDir()+"/" - read := map[string]string{} - for template, text := range texts { - read[strings.TrimPrefix(template, inside)] = text - } - var built []catalogue.Built - for _, a := range m.Build.Artifacts { - sum := sha256.Sum256([]byte("gate\x00" + m.Module + "\x00" + a.Name)) - digest := fmt.Sprintf("sha256:%x", sum) - b := catalogue.Built{Name: a.Name, Kind: a.Kind, Digest: digest} - if a.Name == archive && archive != "" { - b.Texts = read - } - switch a.Kind { - case catalogue.ArtifactImage, catalogue.ArtifactUpstream: - b.Reference = catalogue.ArtifactStoreScheme + m.Module + "/" + a.Name + "@" + digest - default: - b.Reference = catalogue.ArtifactStoreScheme + m.Module + "/" + a.Name + "/blobs/" + digest - } - built = append(built, b) - } - return m.Resolve(built) -} - -// composeEveryMachine raises a throwaway store from the snapshot with this shelf, composes every machine -// twice — the first time as a push does, making each credential, so the second has every consumer's -// grant to compose — and answers each machine's composition. Notes are what of the snapshot could not -// be raised: a refusal to register a module, keep a setting or a pin. -func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[string]catalogue.Manifest, - sources map[string]inventory.Source, trials []string) (map[string]mergeComposed, map[string]mergeTrial, []string, error) { - open, drop, err := throwawayStores(ctx, in.admin) - if err != nil { - return nil, nil, nil, err - } - defer drop() - notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources) - if err != nil { - return nil, nil, notes, err - } - gens, gensErr := generatorsOf(ctx, open) - out := map[string]mergeComposed{} - for pass := 0; pass < 2; pass++ { - for _, m := range in.facts.Machines { - if gensErr != nil { - out[m.Name] = mergeComposed{Problems: []string{"the private network cannot be computed: " + - oneLine(gensErr.Error())}} - continue - } - declared, problems, err := composeSendValidated(ctx, open, m.Name) - if pass == 0 { - continue - } - c := mergeComposed{} - if err != nil { - c.Problems = []string{oneLine(err.Error())} - out[m.Name] = c - continue - } - c.Composes = len(problems) == 0 - c.Problems = problems - c.Resources = resourceNames(declared.Resources) - c.LeftOut = declared.leftOutWhy - for _, o := range declared.withheld { - c.Withheld = append(c.Withheld, o.String()) - } - for _, u := range declared.unbound { - c.Unbound = append(c.Unbound, u.String()) - } - c.Unplaced = declared.unplaced - sort.Strings(c.Withheld) - sort.Strings(c.Unbound) - out[m.Name] = c - } - if ctx.Err() != nil { - return nil, nil, notes, ctx.Err() - } - } - tried := map[string]mergeTrial{} - if gensErr == nil { - for _, module := range trials { - tried[module] = tryAssigning(ctx, open, in.facts, shelf[module], gens) - } - } - return out, tried, notes, nil -} - -// mergeTrial is a module no machine runs yet, assigned for a moment to one that could run it. -type mergeTrial struct { - Machine string `json:"machine,omitempty"` - Refused []string `json:"refused,omitempty"` - Composes bool `json:"composes"` - Said string `json:"said,omitempty"` -} - -// tryAssigning composes a module nobody runs yet on the first machine that has what it needs, and says -// what the node-engine would refuse of it there (issue 236: the refusal came only from the first live -// machine it was assigned to). The assignment is taken back; nothing else in the store moves. -func tryAssigning(ctx context.Context, open *stores, f snapshot.Facts, m catalogue.Manifest, - gens map[string]catalogue.Generator) mergeTrial { - for _, machine := range f.Machines { - has := map[string]bool{} - for _, c := range machine.Capabilities { - has[c.Name] = c.Present - } - fits := true - for _, c := range m.Capabilities { - fits = fits && has[c] - } - if !fits { - continue - } - if _, err := open.inventory.Assign(ctx, machine.Name, m.Module); err != nil { - return mergeTrial{Machine: machine.Described(), Said: oneLine(err.Error())} - } - _, problems, err := composeSendValidated(ctx, open, machine.Name) - _ = open.inventory.Unassign(ctx, machine.Name, m.Module) - t := mergeTrial{Machine: machine.Described()} - if err != nil { - t.Said = oneLine(err.Error()) - return t - } - for _, p := range problems { - if strings.Contains(p, `"`+m.Module+".") || strings.Contains(p, m.Module+":") { - t.Refused = append(t.Refused, p) - } - } - t.Composes = len(problems) == 0 - return t - } - return mergeTrial{Said: "no machine of the mesh has what it needs: " + strings.Join(m.Capabilities, ", ")} -} - -// throwawayStores creates fresh databases for every context the controller keeps in the PostgreSQL at -// admin, brings each to this controller's schema, and answers them opened, with what drops them again. -func throwawayStores(ctx context.Context, admin string) (*stores, func(), error) { - suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000_000) - conn, err := pgx.Connect(ctx, admin) - if err != nil { - return nil, nil, fmt.Errorf("cannot reach the throwaway PostgreSQL: %w", err) - } - defer conn.Close(ctx) - cut := strings.LastIndex(admin, "/") - if cut < 0 { - return nil, nil, fmt.Errorf("%q is not a PostgreSQL URL", admin) - } - query := "" - if q := strings.Index(admin[cut:], "?"); q >= 0 { - query = admin[cut+q:] - } - var names []string - // The stores are found by the environment, so what it said before is what it says after: a gate run - // inside a process with stores of its own — a test, a controller — leaves them as they were. - restore := map[string]*string{} - for _, c := range held { - if was, set := os.LookupEnv(store.Variable(c.name)); set { - restore[c.name] = &was - } else { - restore[c.name] = nil - } - } - drop := func() { - for name, was := range restore { - if was == nil { - _ = os.Unsetenv(store.Variable(name)) - } else { - _ = os.Setenv(store.Variable(name), *was) - } - } - c, err := pgx.Connect(context.Background(), admin) - if err != nil { - return - } - defer c.Close(context.Background()) - for _, n := range names { - _, _ = c.Exec(context.Background(), "drop database if exists "+n+" with (force)") - } - } - for _, c := range held { - name := "gate_" + c.name + "_" + suffix - if _, err := conn.Exec(ctx, "create database "+name); err != nil { - drop() - return nil, nil, fmt.Errorf("cannot create %s: %w", name, err) - } - names = append(names, name) - url := admin[:cut] + "/" + name + query - if err := os.Setenv(store.Variable(c.name), url); err != nil { - drop() - return nil, nil, err - } - migrations, err := c.migrations() - if err != nil { - drop() - return nil, nil, err - } - s, err := store.Open(ctx, c.name) - if err != nil { - drop() - return nil, nil, err - } - _, err = s.Migrate(ctx, migrations) - s.Close() - if err != nil { - drop() - return nil, nil, fmt.Errorf("%s does not migrate: %w", c.name, err) - } - } - open, err := openStores(ctx) - if err != nil { - drop() - return nil, nil, err - } - // The chain lives on the bus, and the gate has none: this mesh is raised for a moment, with a chain of its own in memory - // that no other reads (issue 1121). - open.inventory.UseThrowawayChain() - for _, m := range provided { - if err := open.inventory.Provide(ctx, m); err != nil { - open.Close() - drop() - return nil, nil, err - } - } - if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { - open.Close() - drop() - return nil, nil, err - } - return open, func() { open.Close(); drop() }, nil -} - -// raiseFromFacts puts the snapshot's mesh into a fresh store through the controller's own records: -// every module of the shelf registered, every machine added where it is with what it reported, a key -// for each standing in for the one it holds, its assignments, the seats it holds, its pins and its -// settings, and a stand-in for every secret a person gave. -func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf map[string]catalogue.Manifest, - sources map[string]inventory.Source) ([]string, error) { - inv := open.inventory - var notes []string - names := make([]string, 0, len(shelf)) - for name := range shelf { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - s := sources[name] - if err := inv.RegisterModule(ctx, shelf[name], inventory.Source{Repository: s.Repository, Path: s.Path, - BuiltFrom: s.BuiltFrom}); err != nil { - notes = append(notes, fmt.Sprintf("%s is not registered: %s", name, oneLine(err.Error()))) - } - } - for i, m := range f.Machines { - node, err := inv.AddNodeAs(ctx, m.Name, m.Adopted) - if err != nil { - return notes, fmt.Errorf("%s: %w", m.Name, err) - } - if m.OnNetwork { - endpoint := "" - if m.Public { - endpoint = fmt.Sprintf("192.0.2.%d:51820", i+1) - } - if err := inv.SetPlace(ctx, m.Name, endpoint, m.Site, m.Hub, fmt.Sprintf("10.99.%d.%d", i/250, i%250+1)); err != nil { - return notes, fmt.Errorf("%s: %w", m.Name, err) - } - } - var caps []map[string]any - for _, c := range m.Capabilities { - caps = append(caps, map[string]any{"name": c.Name, "present": c.Present, "detail": c.Detail}) - } - if err := inv.RecordProfile(ctx, node.ID, map[string]any{"architecture": m.Architecture, - "kernel": m.Kernel, "capabilities": caps}); err != nil { - return notes, err - } - // What its node-engine takes, as the mesh composes it today (issue 969): without the statement the gate - // composes a moved module the old way on a machine the mesh hands it to. - if stated, ok := standInHealth(m, f.Taken); ok { - if _, err := inv.RecordHealth(ctx, stated); err != nil { - return notes, err - } - } - for _, record := range []func(context.Context, string, string) error{inv.RecordSealingKey, inv.RecordOverlayKey} { - key, err := standInKey() - if err != nil { - return notes, err - } - if err := record(ctx, node.ID, key); err != nil { - return notes, err - } - } - // The links it faces outside by, which a filter is written around (ADR 0140). A snapshot from a - // controller that did not carry them stands in one for a machine the mesh composes: composing, it - // had reported them. - outward := m.OutwardLinks - if len(outward) == 0 && m.Declaration.Composes { - outward = []string{"outside0"} - } - if len(outward) > 0 { - if err := inv.RecordOutwardLinks(ctx, node.ID, outward); err != nil { - return notes, err - } - } - if m.Account != "" { - if err := inv.SetAccount(ctx, m.Name, m.Account, m.AccountHome); err != nil { - return notes, err - } - } - if m.AgentAccount != "" { - if err := inv.SetAgentAccount(ctx, m.Name, m.AgentAccount, m.AgentAccountHome); err != nil { - return notes, err - } - } - if m.PublicDomain != "" { - if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil { - return notes, err - } - } - } - for _, m := range f.Machines { - for _, module := range m.Assigned { - if _, err := inv.Assign(ctx, m.Name, module); err != nil { - notes = append(notes, fmt.Sprintf("%s cannot be assigned to %s: %s", module, m.Described(), - oneLine(err.Error()))) - } - } - } - for _, s := range f.Seats { - for _, h := range s.Holders { - var err error - if s.Scope == catalogue.ScopeMesh && len(s.Holders) > 1 { - err = inv.AddSeatHolder(ctx, s.Name, s.Scope, h.Machine, h.Module) - } else { - err = inv.HoldSeat(ctx, s.Name, s.Scope, h.Machine, h.Module) - } - if err != nil { - notes = append(notes, fmt.Sprintf("%s's hold of %s is not kept: %s", h.Machine, s.Name, oneLine(err.Error()))) - } - } - } - for _, s := range f.Settings { - if err := inv.KeepSettings(ctx, "", s.Module, standInPaths(s.Values)); err != nil { - notes = append(notes, fmt.Sprintf("the mesh's settings of %s are not kept: %s", s.Module, oneLine(err.Error()))) - } - } - for _, m := range f.Machines { - for _, p := range m.Pins { - if err := inv.PinProvision(ctx, m.Name, p.Provision, p.Machine, p.Module); err != nil { - notes = append(notes, fmt.Sprintf("%s's pin of %s is not kept: %s", m.Described(), p.Provision, - oneLine(err.Error()))) - } - } - for _, s := range m.Settings { - if err := inv.KeepSettings(ctx, m.Name, s.Module, standInPaths(s.Values)); err != nil { - notes = append(notes, fmt.Sprintf("%s's settings of %s are not kept: %s", m.Described(), s.Module, - oneLine(err.Error()))) - } - } - for _, a := range m.Accepted { - standIn := fmt.Sprintf("gate-stand-in-%x", sha256.Sum256([]byte(m.Name+a.Module+a.Name+a.Provider+a.Local)))[:40] - if a.Provider == "" && a.Name == "broker" { - // **The bus credential is an account, not a given value** (novox/hq issue 203): composition asks - // whether one was issued for the module, so the raised store mints the account the mesh issued - // as well as holding the sealed value — otherwise every machine running a module on the bus - // fails to compose, with the change and without, and the gate compares broken to broken. - // A credential the mesh still holds for a module that no longer reads one composes nothing, - // so it is not raised. - if !readsBusCredential(shelf, a.Module) { - continue - } - if _, err := inv.MintBusPassword(ctx, inventory.BusUser{Kind: busKindOf(a.Module), Node: m.Name, - Module: a.Module, Username: broker.Principal{Kind: broker.KindModule, Node: m.Name, - Module: a.Module}.Username()}); err != nil { - notes = append(notes, fmt.Sprintf("%s's bus account for %s is not kept: %s", m.Described(), - a.Module, oneLine(err.Error()))) - continue - } - } - // And the provisioner client's account, the same way (novox/hq ADR 0349 clause 11, design 57 P3). - if a.Provider == "" && a.Name == broker.ProvisionerSecret { - if pm, held := shelf[a.Module]; !held || !runsTheProvisionerClient(pm) { - continue - } - if _, err := inv.MintBusPassword(ctx, inventory.BusUser{Kind: inventory.BusProvisioner, Node: m.Name, - Module: a.Module, Username: broker.Principal{Kind: broker.KindProvisioner, Node: m.Name, - Module: a.Module}.Username()}); err != nil { - notes = append(notes, fmt.Sprintf("%s's provisioner client account for %s is not kept: %s", - m.Described(), a.Module, oneLine(err.Error()))) - continue - } - } - var err error - if a.Provider == "" { - err = inv.AcceptSecretForModule(ctx, m.Name, a.Module, a.Name, standIn) - } else { - err = inv.AcceptSecretForPair(ctx, a.Name, m.Name, a.Module, a.Provider, a.Local, standIn) - } - if err != nil { - notes = append(notes, fmt.Sprintf("%s's given %s for %s is not kept: %s", m.Described(), a.Name, - a.Module, oneLine(err.Error()))) - } - } - } - return notes, nil -} - -// standInPaths is a settings layer with every value the snapshot withheld where a path stands — an -// access's or a place's — given a path of its own: a path that reads as a key is withheld by the scrub, -// and a bare "withheld" is no absolute path, which composition refuses (a machine the mesh composes -// would not compose as the gate raised it). Each stand-in is distinct, so two withheld places stay two. -func standInPaths(values map[string]any) map[string]any { - out := make(map[string]any, len(values)) - for k, v := range values { - out[k] = v - } - if accesses, ok := values["accesses"].(map[string]any); ok { - kept := make(map[string]any, len(accesses)) - for name, at := range accesses { - if withheldPath(at) { - at = "/" + snapshot.Withheld + "/access/" + name - } - kept[name] = at - } - out["accesses"] = kept - } - if places, ok := values["places"].(map[string]any); ok { - kept := make(map[string]any, len(places)) - for name, p := range places { - if place, ok := p.(map[string]any); ok && withheldPath(place["path"]) { - copied := make(map[string]any, len(place)) - for k, v := range place { - copied[k] = v - } - copied["path"] = "/" + snapshot.Withheld + "/place/" + name - p = copied - } - kept[name] = p - } - out["places"] = kept - } - return out -} - -// withheldPath is a value the scrub withheld where a path stood: bare, or keeping a path's shape. -func withheldPath(v any) bool { - return v == snapshot.Withheld || v == "/"+snapshot.Withheld -} - -// readsBusCredential is whether a module of the shelf, or one that came with the controller, declares the -// own secret its bus account is delivered as. -func readsBusCredential(shelf map[string]catalogue.Manifest, module string) bool { - if m, held := shelf[module]; held { - _, reads := m.OwnSecrets["broker"] - return reads - } - for _, m := range provided { - if m.Module == module { - _, reads := m.OwnSecrets["broker"] - return reads - } - } - return false -} - -// standInKey is a key a machine could have reported; its private half is never kept. -func standInKey() (string, error) { - k, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()), nil -} - -// reachOfChange is what a merge of the change would move and build — **the planner's own answer** -// (reachOfMerge), asked of the modules, sources, reads and edges the snapshot carries, never a mapping of -// the gate's own: what a merge touches and what follows it are decided in one place, so the gate and the -// plan a merge makes cannot disagree (novox/hq ADR 0238). -func reachOfChange(f snapshot.Facts, repository string, paths []string, tree string) (mergeReach, error) { - owner, repo, found := strings.Cut(repository, "/") - if !found { - return mergeReach{}, fmt.Errorf("%q is not owner/repository", repository) - } - m := link.SourceMoved{Owner: owner, Repo: repo, Base: "main", Commit: "gate", Paths: paths} - // Which changed directories hold a module, read from the change's tree as the forge's announcer reads - // them at the head (issue 278): what the planner is told of a module the mesh does not hold yet. - if tree != "" { - m.ModuleDirs, m.ModuleDirsSaid = moduleDirsIn(tree, paths), true - for _, p := range paths { - if _, err := os.Stat(filepath.Join(tree, p)); os.IsNotExist(err) { - m.Removed = append(m.Removed, p) - } - } - } - entries, read, edges, err := graphOfFacts(f) - if err != nil { - return mergeReach{}, err - } - return reachOfMerge(m, entries, read, edges), nil -} - -// graphOfFacts is the module graph the snapshot carries, as the planner reads it from the store. -func graphOfFacts(f snapshot.Facts) ([]inventory.Entry, map[string][]inventory.ReadRepository, []inventory.Edge, error) { - var entries []inventory.Entry - read := map[string][]inventory.ReadRepository{} - for _, mod := range f.Modules { - var manifest catalogue.Manifest - if err := json.Unmarshal(mod.Manifest, &manifest); err != nil { - return nil, nil, nil, err - } - entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided, - Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}}) - for _, r := range mod.Reads { - entry := inventory.ReadRepository{Repository: r} - for _, s := range mod.Sources { - if !s.Own && s.Repository == r && len(s.Paths) > 0 { - entry.Paths = s.Paths - } - } - read[mod.Name] = append(read[mod.Name], entry) - } - for _, s := range mod.Sources { - if s.Own && len(s.Paths) > 0 { - read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Own: true, Paths: s.Paths}) - } - } - // A context naming the module's own repository is its own build source (novox/hq ADR 0420 §4). - read[mod.Name] = inventory.OwnRepositoryAsOwn(read[mod.Name], mod.Repository) - } - var edges []inventory.Edge - for _, e := range f.Edges { - edges = append(edges, inventory.Edge{From: e.From, To: e.To, Kind: e.Kind}) - } - return entries, read, edges, nil -} - -// widthOf is how wide the rebuild of a reach is. -func widthOf(r mergeReach) mergeWidthOf { - w := mergeWidthOf{Tiers: len(r.Plan.Tiers), Unread: r.Unread} - for name := range r.Plan.Modules { - w.Modules = append(w.Modules, name) - } - sort.Strings(w.Modules) - return w -} - -// scopeOfReach is the directories, in the change's repository, of the modules a reach rebuilds or adds; -// nil — every definition judged — when there is no reach. -func scopeOfReach(r *mergeReach, repository string) map[string]bool { - if r == nil { - return nil - } - scope := map[string]bool{} - same := link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)} - for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) { - if sameRepository(e.Source.Repository, same) { - scope[strings.Trim(e.Source.Path, "/")] = true - } - } - for _, d := range r.Added { - if d == "." { - d = "" - } - scope[d] = true - } - return scope -} - -// moduleDirsIn are the directories above the changed paths, never the root, that hold a module.json in -// the tree. -func moduleDirsIn(tree string, paths []string) []string { - seen := map[string]bool{} - var out []string - for _, p := range paths { - for dir := filepath.Dir(filepath.Clean(p)); dir != "." && dir != "/" && dir != ""; dir = filepath.Dir(dir) { - if seen[dir] { - continue - } - seen[dir] = true - if _, err := os.Stat(filepath.Join(tree, dir, "module.json")); err == nil { - out = append(out, filepath.ToSlash(dir)) - } - } - } - sort.Strings(out) - return out -} - -// difference is what b has that a has not, and what a has that b has not. -func difference(a, b []string) (added, removed []string) { - for _, x := range b { - if !slices.Contains(a, x) { - added = append(added, x) - } - } - for _, x := range a { - if !slices.Contains(b, x) { - removed = append(removed, x) - } - } - return added, removed -} - -// newOnly is what now has that was had not. -func newOnly(now, was []string) []string { - var out []string - for _, x := range now { - if !slices.Contains(was, x) { - out = append(out, x) - } - } - return out -} - -func firstOr(items, otherwise []string) string { - if len(items) > 0 { - return items[0] - } - if len(otherwise) > 0 { - return otherwise[0] - } - return "it says nothing more" -} - -// standInHealth is the statement the gate gives a machine of the snapshot so that it composes as the mesh does: -// only its contract, which says whether it takes handed files (issue 969). False for a snapshot that carries none. -func standInHealth(m snapshot.Machine, taken time.Time) (inventory.NodeHealth, bool) { - if m.Contract == 0 { - return inventory.NodeHealth{}, false - } - return inventory.NodeHealth{Node: m.Name, Contract: m.Contract, SaidAt: taken, HeardAt: taken}, true -} diff --git a/cmd/mesh-controller/merge_gate_changed_test.go b/cmd/mesh-controller/merge_gate_changed_test.go deleted file mode 100644 index 4772c0d8..00000000 --- a/cmd/mesh-controller/merge_gate_changed_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -func TestTheGateJudgesAFileOfPathsAsItDoesTheSameListOfThem(t *testing.T) { // 1247/AC3 - paths := []string{"modules/a/x.go", "modules/a/y.go", "modules/b/z.go", "README.md"} - file := filepath.Join(t.TempDir(), "changed.txt") - if err := os.WriteFile(file, []byte(strings.Join(paths, "\n")+"\n"), 0o644); err != nil { - t.Fatal(err) - } - fromFile, err := changedPaths("", file) - if err != nil { - t.Fatal(err) - } - fromList, err := changedPaths(strings.Join(paths, ","), "") - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(fromFile, fromList) || len(fromFile) != len(paths) { - t.Errorf("the file gives %v, the list %v", fromFile, fromList) - } - if _, err := changedPaths("", filepath.Join(t.TempDir(), "none")); err == nil { - t.Error("a file that is not there is read as no paths") - } -} diff --git a/cmd/mesh-controller/merge_gate_handing_integration_test.go b/cmd/mesh-controller/merge_gate_handing_integration_test.go deleted file mode 100644 index 58259195..00000000 --- a/cmd/mesh-controller/merge_gate_handing_integration_test.go +++ /dev/null @@ -1,68 +0,0 @@ -//go:build integration - -package main - -import ( - "os" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -func TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - said := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) - for node, contract := range map[string]int{"anchor": link.HandedContract, "laptop": link.HandedContract - 1} { - if _, err := open.inventory.RecordHealth(ctx, inventory.NodeHealth{Node: node, Contract: contract, - SaidAt: said, HeardAt: said}); err != nil { - t.Fatal(err) - } - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - if len(f.Machines) != 2 { - t.Fatalf("the snapshot holds %d machines", len(f.Machines)) - } - want := map[string]bool{} - for _, m := range f.Machines { - if m.Contract == 0 { - t.Fatalf("%s: the snapshot carries no contract", m.Name) - } - want[m.Name] = m.Contract >= link.HandedContract - } - handing := 0 - for _, hands := range want { - if hands { - handing++ - } - } - if handing != 1 { - t.Fatalf("one machine takes handed files in the snapshot, got %v", want) - } - - raised, drop, err := throwawayStores(ctx, os.Getenv("MESH_TEST_POSTGRES")) - if err != nil { - t.Fatal(err) - } - defer drop() - if _, err := raiseFromFacts(ctx, raised, f, map[string]catalogue.Manifest{}, map[string]inventory.Source{}); err != nil { - t.Fatal(err) - } - for name, hands := range want { - in, err := readComposeInput(ctx, raised) - if err != nil { - t.Fatal(err) - } - node, _ := nodeOf(in.Input, name) - if got := node.Stated && node.Contract >= compose.HandedContract; got != hands { - t.Errorf("%s: the gate raises it as taking handed files = %v (%v), the mesh says %v", name, got, err, hands) - } - } -} diff --git a/cmd/mesh-controller/merge_gate_handing_test.go b/cmd/mesh-controller/merge_gate_handing_test.go deleted file mode 100644 index 73c5a9da..00000000 --- a/cmd/mesh-controller/merge_gate_handing_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "testing" - "time" - - snapshot "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/link" -) - -// Issue 969: the gate composes a machine as the mesh does, so a moved module is handed where the machine takes -// handed files and composed the old way where it does not. -func TestIssue969TheGateGivesAMachineItsContractSoItTakesHandedFiles(t *testing.T) { - taken := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) - - stated, ok := standInHealth(snapshot.Machine{Name: "f35", Contract: link.HandedContract}, taken) - if !ok || stated.Contract < link.HandedContract { - t.Fatalf("a machine whose engine takes handed files must be given that in the gate: %+v, %v", stated, ok) - } - old, ok := standInHealth(snapshot.Machine{Name: "e12", Contract: link.HandedContract - 1}, taken) - if !ok || old.Contract >= link.HandedContract { - t.Fatalf("a machine whose engine is older must not take handed files: %+v, %v", old, ok) - } - none, ok := standInHealth(snapshot.Machine{Name: "d01"}, taken) - if ok { - t.Fatalf("a snapshot that carries no contract states nothing: %+v, %v", none, ok) - } -} - -// Issue 969, through the store: a machine whose engine stated the handed contract is in the snapshot with it, the -// gate raises it as taking handed files, and one whose statement is below it is raised as not taking them. Run in -// the merge check with the other tests that need a store; the composition itself, handed here and refused there, is -// the catalogue's TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded. diff --git a/cmd/mesh-controller/merge_gate_held.go b/cmd/mesh-controller/merge_gate_held.go deleted file mode 100644 index 3974e5ab..00000000 --- a/cmd/mesh-controller/merge_gate_held.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" -) - -// Config values held for the keys an open pull request introduces (novox/hq ADR 0502 clause 4, issue 1826): the gate -// lays each approved one over its module's values on its node in the composition with the change, and in that one -// only. In the mesh as it is the key is not declared, so the value would be refused there. - -// heldValue is one approved held value as the build agent writes it (--held): the value of Key for Module on Node, -// a machine's real name, which the snapshot holds only as a pseudonym. -type heldValue struct { - Pull string `json:"pull"` - Head string `json:"head,omitempty"` - Node string `json:"node"` - Module string `json:"module"` - Key string `json:"key"` - Value json.RawMessage `json:"value"` - Approved string `json:"approved,omitempty"` - By string `json:"by,omitempty"` - At string `json:"at,omitempty"` -} - -// readHeld reads the file --held names; none named, none held. -func readHeld(path string) ([]heldValue, error) { - if path == "" { - return nil, nil - } - raw, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var held []heldValue - if err := json.Unmarshal(raw, &held); err != nil { - return nil, fmt.Errorf("the held values are not readable: %w", err) - } - for _, h := range held { - if h.Node == "" || h.Module == "" || h.Key == "" || len(h.Value) == 0 { - return nil, fmt.Errorf("a held value of %s names no node, module, key or value", h.Pull) - } - } - return held, nil -} - -// layHeld is the snapshot with each held value laid as the value of its key in its node's layer of its module, and what -// it said of them. The snapshot given is not changed. **A held value for a machine the snapshot does not hold, or a -// module the change does not define, is said and not laid**, never dropped silently; one for a module not assigned to -// its machine is laid and said. The notes name machines as the snapshot does (roles and pseudonym), not by their names. -func layHeld(f snapshot.Facts, held []heldValue, shelf map[string]catalogue.Manifest) (snapshot.Facts, []string) { - if len(held) == 0 { - return f, nil - } - out := f - out.Machines = make([]snapshot.Machine, len(f.Machines)) - copy(out.Machines, f.Machines) - var notes, laid []string - pulls := map[string]bool{} - for _, h := range held { - at := -1 - pseudonym := snapshot.Pseudonym("machine", h.Node) - for i, m := range out.Machines { - if m.Name == pseudonym { - at = i - } - } - if at < 0 { - notes = append(notes, fmt.Sprintf("the value held for %s of %s (%s=%s) is not composed: the snapshot holds no machine %s", - h.Module, h.Pull, h.Key, h.Value, pseudonym)) - continue - } - if _, defined := shelf[h.Module]; !defined { - notes = append(notes, fmt.Sprintf("the value held for %s of %s (%s=%s) is not composed: the change defines no module %s", - h.Module, h.Pull, h.Key, h.Value, h.Module)) - continue - } - var value any - if err := json.Unmarshal(h.Value, &value); err != nil { - notes = append(notes, fmt.Sprintf("the value held for %s of %s (%s) is not composed: it is not a value: %v", - h.Module, h.Pull, h.Key, err)) - continue - } - m := &out.Machines[at] - layers := make([]snapshot.Settings, len(m.Settings)) - copy(layers, m.Settings) - found := false - for i := range layers { - if layers[i].Module == h.Module { - values := make(map[string]any, len(layers[i].Values)+1) - for k, v := range layers[i].Values { - values[k] = v - } - values[h.Key] = value - layers[i].Values = values - found = true - } - } - if !found { - layers = append(layers, snapshot.Settings{Module: h.Module, Values: map[string]any{h.Key: value}}) - sort.Slice(layers, func(a, b int) bool { return layers[a].Module < layers[b].Module }) - } - m.Settings = layers - assigned := false - for _, a := range m.Assigned { - assigned = assigned || a == h.Module - } - by := "" - if h.Approved != "" { - by = ", approved by " + h.Approved - } - line := fmt.Sprintf("%s on %s %s=%s%s", h.Module, m.Described(), h.Key, h.Value, by) - if !assigned { - line += " (not assigned there in the snapshot)" - } - laid = append(laid, line) - pulls[h.Pull] = true - } - if len(laid) > 0 { - var names []string - for p := range pulls { - names = append(names, p) - } - sort.Strings(names) - notes = append([]string{fmt.Sprintf("composed with %d value(s) held for %s: %s", len(laid), strings.Join(names, ", "), - strings.Join(laid, "; "))}, notes...) - } - return out, notes -} diff --git a/cmd/mesh-controller/merge_gate_held_test.go b/cmd/mesh-controller/merge_gate_held_test.go deleted file mode 100644 index 922a02d0..00000000 --- a/cmd/mesh-controller/merge_gate_held_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" -) - -func heldGateFacts() snapshot.Facts { - return snapshot.Facts{Machines: []snapshot.Machine{ - {Name: snapshot.Pseudonym("machine", "ace"), Roles: []string{"the build node"}, Assigned: []string{"n8n"}, - Settings: []snapshot.Settings{{Module: "n8n", Values: map[string]any{"port": 5678.0}}}}, - {Name: snapshot.Pseudonym("machine", "g14")}, - }} -} - -var heldShelf = map[string]catalogue.Manifest{"n8n": {Module: "n8n"}} - -// 1826/AC3: a held value is laid in its node's layer of its module, beside what the layer holds, and said. -func TestAHeldValueIsLaidOverItsNodesLayerAndSaid(t *testing.T) { - f := heldGateFacts() - got, notes := layHeld(f, []heldValue{{Pull: "novox/mesh-catalog#434", Node: "ace", Module: "n8n", Key: "media-gid", - Value: []byte("2000"), Approved: "the operator"}}, heldShelf) - layer := got.Machines[0].Settings[0].Values - if layer["media-gid"] != 2000.0 || layer["port"] != 5678.0 { - t.Fatalf("the layer holds %v", layer) - } - if _, has := f.Machines[0].Settings[0].Values["media-gid"]; has { - t.Fatal("the snapshot given was changed") - } - if len(notes) != 1 || !strings.Contains(notes[0], "composed with 1 value(s) held for novox/mesh-catalog#434") || - !strings.Contains(notes[0], "media-gid=2000") || !strings.Contains(notes[0], "approved by the operator") || - strings.Contains(notes[0], "ace") { - t.Fatalf("said %q", notes) - } -} - -// 1826/AC3: a machine with no layer of the module gets one. -func TestAHeldValueMakesTheLayerAMachineLacks(t *testing.T) { - got, _ := layHeld(heldGateFacts(), []heldValue{{Pull: "p", Node: "g14", Module: "n8n", Key: "k", Value: []byte(`"x"`)}}, heldShelf) - s := got.Machines[1].Settings - if len(s) != 1 || s[0].Module != "n8n" || s[0].Values["k"] != "x" { - t.Fatalf("layers %+v", s) - } -} - -// 1826/AC3: a held value for a machine or a module the snapshot or the change does not hold is said, never laid. -func TestAHeldValueForWhatIsNotHeldIsSaidNotLaid(t *testing.T) { - f := heldGateFacts() - got, notes := layHeld(f, []heldValue{ - {Pull: "p", Node: "nowhere", Module: "n8n", Key: "k", Value: []byte("1")}, - {Pull: "p", Node: "ace", Module: "ghost", Key: "k", Value: []byte("1")}, - }, heldShelf) - if len(notes) != 2 || !strings.Contains(notes[0], "holds no machine") || !strings.Contains(notes[1], "defines no module ghost") { - t.Fatalf("said %q", notes) - } - if _, has := got.Machines[0].Settings[0].Values["k"]; has { - t.Fatal("laid") - } -} - -// 1826/AC3: none held lays nothing and says nothing; the file is read and refuses a value without a node. -func TestNoHeldValuesLayNothingAndTheFileIsRead(t *testing.T) { - if got, notes := layHeld(heldGateFacts(), nil, heldShelf); len(notes) != 0 || len(got.Machines) != 2 { - t.Fatalf("%v %v", got, notes) - } - dir := t.TempDir() - good := filepath.Join(dir, "good.json") - os.WriteFile(good, []byte(`[{"pull":"p","node":"ace","module":"n8n","key":"k","value":1}]`), 0o644) - if h, err := readHeld(good); err != nil || len(h) != 1 || h[0].Key != "k" { - t.Fatalf("%v %v", h, err) - } - bad := filepath.Join(dir, "bad.json") - os.WriteFile(bad, []byte(`[{"pull":"p","module":"n8n","key":"k","value":1}]`), 0o644) - if _, err := readHeld(bad); err == nil { - t.Fatal("a value without a node was read") - } - if h, err := readHeld(""); h != nil || err != nil { - t.Fatalf("%v %v", h, err) - } -} diff --git a/cmd/mesh-controller/merge_gate_integration_test.go b/cmd/mesh-controller/merge_gate_integration_test.go deleted file mode 100644 index fca93fd6..00000000 --- a/cmd/mesh-controller/merge_gate_integration_test.go +++ /dev/null @@ -1,582 +0,0 @@ -//go:build integration - -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" -) - -func catalogueMesh(t *testing.T) (snapshot.Facts, map[string]string) { - t.Helper() - open := aMesh(t) - ctx := t.Context() - manifests := map[string]string{ - "objects": `{"module":"objects","version":"1", - "provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}], - "receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`, - "resolver": `{"module":"resolver","version":"1", - "provides":[{"name":"wildcard-resolution","scope":"mesh","identity":false}]}`, - "networkmanager": `{"module":"networkmanager","version":"1","requires":["wildcard-resolution"]}`, - "album": `{"module":"album","version":"1","requires":["s3-bucket"]}`, - "lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"], - "resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`, - } - for name, raw := range manifests { - m, err := catalogue.ParseManifest([]byte(raw)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - } - for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "resolver"}, {"laptop", "networkmanager"}, - {"laptop", "album"}, {"laptop", "lemurs"}} { - if _, err := assign(ctx, open, a[0], a[1]); err != nil { - t.Fatalf("assign %s %s: %v", a[0], a[1], err) - } - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - return f, manifests -} - -func gateJudged(t *testing.T, f snapshot.Facts, tree string, changed ...string) mergeVerdict { - t.Helper() - admin := os.Getenv("MESH_TEST_POSTGRES") - in := mergeCheckInput{facts: f, admin: admin, changed: changed} - if tree != "" { - in.repository, in.tree = "novox/mesh-catalog", tree - } - v, err := judgeChange(t.Context(), in) - if err != nil { - t.Fatal(err) - } - return v -} - -func TestTheMeshAsItIsPassesTheGate(t *testing.T) { - f, manifests := catalogueMesh(t) - for _, m := range f.Machines { - if !m.Declaration.Composes { - t.Fatalf("the mesh itself does not compose: %+v", m.Declaration) - } - } - v := gateJudged(t, f, aTree(t, manifests)) - if v.Verdict != "pass" { - t.Fatalf("an unchanged catalogue does not pass:\n%s", v.Report()) - } - for _, m := range v.Machines { - if !m.Base.Composes || !m.Change.Composes { - t.Errorf("%s does not compose in the gate's store as it does on the mesh: %+v / %+v", m.Described, m.Base, m.Change) - } - } -} - -func TestIssue263AnIdentityARealMachineNameOverflowsFailsThePullRequest(t *testing.T) { - f, manifests := catalogueMesh(t) - tree := aTree(t, withEdit(manifests, "networkmanager", - `{"module":"networkmanager","version":"1","requires":["wildcard-resolution","s3-bucket"]}`)) - v := gateJudged(t, f, tree) - if v.Verdict != "fail" { - t.Fatalf("the overflow passed the gate:\n%s", v.Report()) - } - report := v.Report() - if !strings.Contains(report, "networkmanager") || !strings.Contains(report, "s3-bucket") { - t.Errorf("the refusal does not name the module and the provision:\n%s", report) - } -} - -func TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest(t *testing.T) { - f, manifests := catalogueMesh(t) - broken := `{"module":"lemurs","version":"1","capabilities":["systemd"], - "resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}` - v := gateJudged(t, f, aTree(t, withEdit(manifests, "lemurs", broken))) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "lemurs") { - t.Fatalf("a service the node-engine refuses passed the gate:\n%s", v.Report()) - } - laptop := snapshot.Pseudonym("machine", "laptop") - if !strings.Contains(v.Report(), laptop) { - t.Errorf("the refusal does not name the machine it would be refused on:\n%s", v.Report()) - } - - // And as a new module, which no machine runs: tried on one that could. - newcomer := strings.ReplaceAll(broken, `"lemurs"`, `"greeter"`) - newcomer = strings.ReplaceAll(newcomer, "lemurs.service", "greeter.service") - v = gateJudged(t, f, aTree(t, withEdit(manifests, "greeter", newcomer))) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "greeter, assigned to") { - t.Fatalf("a new module the node-engine would refuse passed the gate:\n%s", v.Report()) - } -} - -func TestAManifestTheRunningControllerCannotReadFailsThePullRequest(t *testing.T) { - f, manifests := catalogueMesh(t) - v := gateJudged(t, f, aTree(t, withEdit(manifests, "album", - `{"module":"album","version":"1","requires":["s3-bucket"],"a-field-of-a-newer-controller":true}`))) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "refuses it") { - t.Fatalf("a manifest this controller cannot read passed:\n%s", v.Report()) - } -} - -func TestAModuleAMachineRunsRemovedFromItsSourceFails(t *testing.T) { - f, manifests := catalogueMesh(t) - v := gateJudged(t, f, aTree(t, withEdit(manifests, "album", ""))) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "album is removed") { - t.Fatalf("removing a module a machine runs passed:\n%s", v.Report()) - } -} - -func TestIssue278AWideRebuildIsSaidBeforeTheMerge(t *testing.T) { - f, manifests := catalogueMesh(t) - was := wideRebuild - wideRebuild = 2 - t.Cleanup(func() { wideRebuild = was }) - for _, file := range []string{"modules/showcase/index.ts", "merge-check.sh", "README.md"} { - v := gateJudged(t, f, aTree(t, manifests), file) - if v.Width == nil || len(v.Width.Modules) != 0 || len(v.Width.Unread) != 1 || v.Verdict != "pass" { - t.Fatalf("%s, read by no build, reads %+v, %s", file, v.Width, v.Verdict) - } - if !strings.Contains(v.Report(), "read by no module's build") { - t.Errorf("why %s rebuilds nothing is not said:\n%s", file, v.Report()) - } - } - v := gateJudged(t, f, aTree(t, manifests), "modules/album/x.ts", "modules/objects/x.go", "modules/resolver/x.go") - if v.Width == nil || len(v.Width.Modules) < 3 || v.Verdict != "warning" { - t.Fatalf("a rebuild wider than the bound is not warned of: %+v, %s", v.Width, v.Verdict) - } - // With the reference module's definition in the tree, its directory is a module, held or not. - withShowcase := withEdit(manifests, "showcase", `{"module":"showcase","version":"1"}`) - v = gateJudged(t, f, aTree(t, withShowcase), "modules/showcase/index.ts") - if v.Width == nil || len(v.Width.Modules) != 0 || len(v.Width.Unread) != 0 { - t.Fatalf("a file of a module nobody holds reads %+v", v.Width) - } - v = gateJudged(t, f, aTree(t, manifests), "modules/album/module.json") - if v.Width == nil || strings.Join(v.Width.Modules, ",") != "album" || v.Verdict != "pass" { - t.Fatalf("a change to one module's directory reads %+v, %s", v.Width, v.Verdict) - } -} - -func TestADeliveryGroupsHeadsAreComposedTogether(t *testing.T) { - f, manifests := catalogueMeshWithAnApp(t) - needsTheApp := withEdit(manifests, "album", `{"module":"album","version":"1","requires":["s3-bucket","app-api"]}`) - catTree := aTree(t, needsTheApp) - alone := gateJudged(t, f, catTree, "modules/album/module.json") - if alone.Verdict != "fail" { - t.Fatalf("a requirement nothing provides passed alone:\n%s", alone.Report()) - } - app := t.TempDir() - if err := os.WriteFile(filepath.Join(app, "module.json"), []byte(`{"module":"app","version":"1", - "provides":[{"name":"app-api","scope":"mesh","identity":false}]}`), 0o644); err != nil { - t.Fatal(err) - } - in := mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"), repository: "novox/mesh-catalog", - tree: catTree, changed: []string{"modules/album/module.json"}, - group: []groupChange{{Repository: "novox/app", Tree: app, Changed: []string{"module.json"}}}} - together, err := judgeChange(t.Context(), in) - if err != nil { - t.Fatal(err) - } - if together.Verdict == "fail" { - t.Fatalf("the group composed together fails:\n%s", together.Report()) - } - if together.Modules["app"] != "changed" || together.Modules["album"] != "changed" { - t.Fatalf("the group's heads were not both laid over the mesh: %v", together.Modules) - } -} - -func catalogueMeshWithAnApp(t *testing.T) (snapshot.Facts, map[string]string) { - t.Helper() - open := aMesh(t) - ctx := t.Context() - manifests := map[string]string{ - "objects": `{"module":"objects","version":"1", - "provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}], - "receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`, - "album": `{"module":"album","version":"1","requires":["s3-bucket"]}`, - } - for name, raw := range manifests { - m, err := catalogue.ParseManifest([]byte(raw)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - } - if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, - inventory.Source{Repository: "novox/app", BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "app"}, {"laptop", "album"}} { - if _, err := assign(ctx, open, a[0], a[1]); err != nil { - t.Fatalf("assign %s %s: %v", a[0], a[1], err) - } - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - return f, manifests -} - -func busMesh(t *testing.T) snapshot.Facts { - t.Helper() - open := aMesh(t) - ctx := t.Context() - m, err := catalogue.ParseManifest([]byte(`{"module":"speaker","version":"1", - "own-secrets":{"broker":"/var/lib/speaker/broker"}}`)) - if err != nil { - t.Fatal(err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/speaker", BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - if _, err := assign(ctx, open, "laptop", "speaker"); err != nil { - t.Fatal(err) - } - user := broker.Principal{Kind: broker.KindModule, Node: "laptop", Module: "speaker"}.Username() - password, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{Username: user, Kind: inventory.BusModule, - Node: "laptop", Module: "speaker"}) - if err != nil { - t.Fatal(err) - } - if err := open.inventory.AcceptSecretForModule(ctx, "laptop", "speaker", "broker", - `{"user":"`+user+`","password":"`+password+`"}`); err != nil { - t.Fatal(err) - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - for _, mc := range f.Machines { - if !mc.Declaration.Composes { - t.Fatalf("the mesh itself does not compose: %+v", mc.Declaration) - } - } - return f -} - -func TestIssue285AModuleOnTheBusComposesInTheGate(t *testing.T) { - f := busMesh(t) - v := gateJudged(t, f, "") - if v.Verdict != "pass" { - t.Fatalf("the mesh as it is does not pass:\n%s", v.Report()) - } - for _, m := range v.Machines { - if !m.Base.Composes { - t.Errorf("%s composes on the mesh and not in the gate: %v", m.Described, m.Base.Problems) - } - } - if !strings.Contains(v.Summary, "2 of 2 compose") { - t.Errorf("the summary does not say every machine composes: %s", v.Summary) - } -} - -func TestIssue285AMachineTheGateCannotRaiseIsAnErrorNeverAPass(t *testing.T) { - f := busMesh(t) - for i := range f.Machines { - // A fact the snapshot does not carry: the module's credential, gone from the laptop's. - var kept []snapshot.Accepted - for _, a := range f.Machines[i].Accepted { - if a.Name != "broker" { - kept = append(kept, a) - } - } - f.Machines[i].Accepted = kept - } - v := gateJudged(t, f, "") - if v.Verdict != "error" { - t.Fatalf("a gate whose mesh does not compose said %s:\n%s", v.Verdict, v.Report()) - } - if len(v.Errors) != 1 || !strings.Contains(v.Errors[0], "speaker") { - t.Errorf("the error does not name what could not be raised: %v", v.Errors) - } -} - -func TestAGroupsMoveOfAModuleIsNeitherTwoDefinitionsNorARemoval(t *testing.T) { - const catalogueRepo, controllerRepo = "novox/mesh-catalog", "novox/mesh-controller" - built := func(repo, path string) inventory.Source { - return inventory.Source{Repository: "http://forge.internal:20000/" + repo + ".git", Path: path, BuiltFrom: "c0ffee"} - } - sources := map[string]inventory.Source{"mover": built(catalogueRepo, "modules/mover")} - raw := `{"module":"mover","version":"1"}` - base := map[string]catalogue.Manifest{} - m, err := catalogue.ParseManifest([]byte(raw)) - if err != nil { - t.Fatal(err) - } - base["mover"] = m - facts := snapshot.Facts{Machines: []snapshot.Machine{{Name: "anchor", Assigned: []string{"mover"}}}} - - catalogueWithout := aTree(t, map[string]string{"other": `{"module":"other","version":"1"}`}) - controllerWith := func() string { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "modules", "mover"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "modules", "mover", "module.json"), []byte(raw), 0o644); err != nil { - t.Fatal(err) - } - return dir - }() - removes := groupChange{Repository: catalogueRepo, Tree: catalogueWithout, Changed: []string{"modules/mover/module.json"}} - defines := groupChange{Repository: controllerRepo, Tree: controllerWith, Changed: []string{"modules/mover/module.json"}} - - lay := func(heads ...groupChange) (map[string]catalogue.Manifest, []string, map[string]string) { - t.Helper() - moves, err := movesInGroup(heads, sources) - if err != nil { - t.Fatal(err) - } - at := sources - if len(moves) > 0 { - at = map[string]inventory.Source{} - for k, v := range sources { - at[k] = v - } - for name, mv := range moves { - o := at[name] - o.Repository, o.Path = mv.To, mv.Dir - at[name] = o - } - } - shelf, said := base, map[string]string{} - var failures []string - for _, h := range heads { - var f []string - if shelf, f, err = shelfWithChange(shelf, at, facts, h.Repository, h.Tree, said, nil); err != nil { - t.Fatal(err) - } - failures = append(failures, f...) - } - for name := range moves { - said[name] = "moved" - } - return shelf, failures, said - } - for _, order := range [][]groupChange{{removes, defines}, {defines, removes}} { - shelf, failures, said := lay(order...) - if len(failures) > 0 { - t.Fatalf("a move of a module machines run, laid %s first, was refused: %v", order[0].Repository, failures) - } - if said["mover"] != "moved" { - t.Errorf("the move reads %v, not moved", said) - } - if _, held := shelf["mover"]; !held { - t.Errorf("the moved module is not on the shelf after laying %s first", order[0].Repository) - } - } - // Alone, a head is still refused: a second definition (issue 239), and a removal of what runs (ADR 0236). - if _, failures, _ := lay(defines); len(failures) != 1 || !strings.Contains(failures[0], "issue 239") { - t.Errorf("a definition of a module another repository builds, alone: %v", failures) - } - if _, failures, _ := lay(removes); len(failures) != 1 || !strings.Contains(failures[0], "ADR 0236") { - t.Errorf("the removal of a module machines run, alone: %v", failures) - } -} - -func TestADeliveryGroupMovingAModuleMachinesRunPassesTheGate(t *testing.T) { - f, manifests := catalogueMeshWithAnApp(t) - catTree := aTree(t, withEdit(manifests, "album", "")) - app := t.TempDir() - // The app module is built from this repository's root, and stays there: only album moves. - if err := os.WriteFile(filepath.Join(app, "module.json"), []byte(`{"module":"app","version":"1"}`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(app, "modules", "album"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(app, "modules", "album", "module.json"), []byte(manifests["album"]), 0o644); err != nil { - t.Fatal(err) - } - removes := groupChange{Repository: "novox/mesh-catalog", Tree: catTree, Changed: []string{"modules/album/module.json"}} - defines := groupChange{Repository: "novox/app", Tree: app, Changed: []string{"modules/album/module.json"}} - judge := func(first groupChange, rest ...groupChange) mergeVerdict { - v, err := judgeChange(t.Context(), mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"), - repository: first.Repository, tree: first.Tree, changed: first.Changed, group: rest}) - if err != nil { - t.Fatal(err) - } - return v - } - for _, v := range []mergeVerdict{judge(removes, defines), judge(defines, removes)} { - if v.Verdict == "fail" || v.Modules["album"] != "moved" { - t.Fatalf("a group moving a module machines run: %v\n%s", v.Modules, v.Report()) - } - } - if v := judge(defines); v.Verdict != "fail" { - t.Errorf("a definition of a module another repository builds passed alone:\n%s", v.Report()) - } - if v := judge(removes); v.Verdict != "fail" { - t.Errorf("the removal of a module machines run passed alone:\n%s", v.Report()) - } -} - -func TestTheGateRefusesAnArtifactContextNamingAnotherRepository(t *testing.T) { - withContext := func(repo string) string { - return `{"module":"proxy","version":"1","build":{"artifacts":[{"name":"server","kind":"image","from":"Dockerfile",` + - `"context":{"seat":"git","repository":"` + repo + `","ref":"main"}}]}}` - } - judge := func(repo string) []string { - t.Helper() - tree := aTree(t, map[string]string{"proxy": withContext(repo)}) - _, failures, err := shelfWithChange(map[string]catalogue.Manifest{}, map[string]inventory.Source{}, - snapshot.Facts{}, "novox/mesh-controller", tree, map[string]string{}, nil) - if err != nil { - t.Fatal(err) - } - return failures - } - const want = "proxy builds artifact server from novox/mesh-catalog: a module lives whole in one repository (novox/hq ADR 0420 §1)" - failures := judge("novox/mesh-catalog") - if len(failures) != 1 || !strings.Contains(failures[0], want) { - t.Errorf("a context naming another repository: %v, want it to say %q", failures, want) - } - for _, f := range judge("novox/mesh-controller") { - if strings.Contains(f, "ADR 0420 §1") { - t.Errorf("a context naming the module's own repository was refused: %s", f) - } - } -} - -func TestAMemberJudgedAloneSeesAMoveThroughTheClonesBeside(t *testing.T) { - const catalogueRepo, controllerRepo = "novox/mesh-catalog", "novox/mesh-controller" - raw := `{"module":"mover","version":"1"}` - sources := map[string]inventory.Source{"mover": {Repository: "http://forge.internal:20000/" + catalogueRepo + ".git", - Path: "modules/mover", BuiltFrom: "c0ffee"}} - // A tree is written into dir: defining mover, or holding only another module. - write := func(dir string, defines bool) string { - name, body := "other", `{"module":"other","version":"1"}` - if defines { - name, body = "mover", raw - } - if err := os.MkdirAll(filepath.Join(dir, "modules", name), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "modules", name, "module.json"), []byte(body), 0o644); err != nil { - t.Fatal(err) - } - return dir - } - defining := func() string { return write(t.TempDir(), true) } - without := func() string { return write(t.TempDir(), false) } - // beside lays out a MESH_CHECK_BESIDE directory holding the named repositories' trees, each defining mover or not. - beside := func(trees map[string]bool) string { - root := t.TempDir() - for name, defines := range trees { - write(filepath.Join(root, name), defines) - } - return root - } - moves := func(repository, tree, besideRoot string) map[string]moveOfModule { - t.Helper() - got, err := movesOfChange(mergeCheckInput{repository: repository, tree: tree, beside: besideRoot}, sources) - if err != nil { - t.Fatal(err) - } - return got - } - // The controller's member: defines mover; the catalogue beside has removed it. - if got := moves(controllerRepo, defining(), beside(map[string]bool{"mesh-catalog": false})); got["mover"].To != controllerRepo { - t.Errorf("the controller's change defining mover, the catalogue beside without it: moves %v", got) - } - // The sources the gate then judges with: built from the controller, the definition is not a second one (issue 239); - // unmoved, it still is. - judge := func(at map[string]inventory.Source) []string { - t.Helper() - _, failures, err := shelfWithChange(map[string]catalogue.Manifest{}, at, snapshot.Facts{}, controllerRepo, defining(), - map[string]string{}, nil) - if err != nil { - t.Fatal(err) - } - return failures - } - mv := moves(controllerRepo, defining(), beside(map[string]bool{"mesh-catalog": false}))["mover"] - moved := map[string]inventory.Source{"mover": {Repository: mv.To, Path: mv.Dir, BuiltFrom: "c0ffee"}} - if f := judge(moved); len(f) != 0 { - t.Errorf("a definition of a module the catalogue beside removed was refused: %v", f) - } - if f := judge(sources); len(f) != 1 || !strings.Contains(f[0], "issue 239") { - t.Errorf("with the catalogue beside at main, the definition: %v, want the issue 239 refusal", f) - } - if got := moves(controllerRepo, defining(), beside(map[string]bool{"mesh-catalog": true})); len(got) != 0 { - t.Errorf("the catalogue beside at main still defines mover, yet moves %v", got) - } - // The catalogue's member: removes mover; the controller beside defines it. - if got := moves(catalogueRepo, without(), beside(map[string]bool{"mesh-controller": true})); got["mover"].To != controllerRepo { - t.Errorf("the catalogue's change removing mover, the controller beside defining it: moves %v", got) - } - if got := moves(catalogueRepo, without(), beside(map[string]bool{"mesh-controller": false})); len(got) != 0 { - t.Errorf("the controller beside at main does not define mover, yet moves %v", got) - } - if got := moves(catalogueRepo, without(), ""); len(got) != 0 { - t.Errorf("nothing beside, yet moves %v", got) - } -} - -// 1826/AC3 (integration level, a throwaway store): a change whose container says ${config:media-gid} fails the gate for -// want of the value, passes it with the value the operator approved held for its pull request, and the mesh as it is -// composes the same either way. -func TestAChangeNeedingAHeldValueFailsWithoutItAndPassesWithIt(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - trunk := `{"module":"n8n","version":"1","resources":[{"id":"server","type":"container","name":"n8n", - "image":"n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0"}]}` - m, err := catalogue.ParseManifest([]byte(trunk)) - if err != nil { - t.Fatal(err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/n8n", BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - if _, err := assign(ctx, open, "laptop", "n8n"); err != nil { - t.Fatal(err) - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - for i := range f.Machines { - f.Machines[i].Capabilities = append(f.Machines[i].Capabilities, snapshot.Capability{Name: "container-groups", Present: true}) - } - tree := aTree(t, map[string]string{"n8n": n8nWithAGroup}) - judge := func(held []heldValue) mergeVerdict { - v, err := judgeChange(t.Context(), mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"), - repository: "novox/mesh-catalog", tree: tree, changed: []string{"modules/n8n/module.json"}, held: held}) - if err != nil { - t.Fatal(err) - } - return v - } - without := judge(nil) - if without.Verdict != "fail" || !strings.Contains(without.Report(), "media-gid") { - t.Fatalf("a required value nothing sets passed, or failed for another reason:\n%s", without.Report()) - } - with := judge([]heldValue{{Pull: "novox/mesh-catalog#434", Node: "laptop", Module: "n8n", Key: "media-gid", - Value: []byte("2000"), Approved: "the operator"}}) - if with.Verdict == "fail" || with.Verdict == "error" { - t.Fatalf("the held value did not make it compose:\n%s", with.Report()) - } - if !strings.Contains(with.Report(), "composed with 1 value(s) held for novox/mesh-catalog#434") { - t.Fatalf("the value laid is not said:\n%s", with.Report()) - } - for i := range with.Machines { - if with.Machines[i].Base.Composes != without.Machines[i].Base.Composes { - t.Fatalf("the mesh as it is composes differently with a held value: %+v / %+v", with.Machines[i].Base, without.Machines[i].Base) - } - } -} diff --git a/cmd/mesh-controller/merge_gate_test.go b/cmd/mesh-controller/merge_gate_test.go deleted file mode 100644 index 7960ea21..00000000 --- a/cmd/mesh-controller/merge_gate_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// The merge gate (novox/hq to-be 45 §9): a change judged against every machine of the snapshot, by the -// incidents it is written from. Each case raises a mesh, takes its snapshot, and judges a pull request's -// tree against it in throwaway stores of its own. - -// catalogueMesh is two machines and a catalogue repository: a resolver and an object store on the -// anchor, and on the laptop a network manager requiring the resolver, an album requiring the object -// store, and a login manager. Every module is registered from novox/mesh-catalog, as the mesh's are. - -// aTree is a checkout of the catalogue repository holding these manifests. -func aTree(t *testing.T, manifests map[string]string) string { - t.Helper() - dir := t.TempDir() - for name, raw := range manifests { - at := filepath.Join(dir, "modules", name) - if err := os.MkdirAll(at, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(at, "module.json"), []byte(raw), 0o644); err != nil { - t.Fatal(err) - } - } - return dir -} - -func withEdit(manifests map[string]string, name, raw string) map[string]string { - out := map[string]string{} - for k, v := range manifests { - out[k] = v - } - if raw == "" { - delete(out, name) - } else { - out[name] = raw - } - return out -} - -// The mesh as it is passes: every machine composes in the gate's store as the controller composed it. - -// **Issue 263**: a change makes the network manager require the object store's provision; on a machine -// whose name is six characters its identity is 26 against a bound of 20. Refused in the pull request, -// naming the module, and not on the anchor after it merged. - -// **Issue 236**: a login manager's service that omits its state passed the catalogue check and was -// refused whole by the node-engine on the first machine. A change to a module a machine runs, and a -// module nobody runs yet, are both refused before merge, naming the machine and the module. - -// **Version skew**: a manifest the controller judging it cannot read — the one the mesh runs, for a -// catalogue change — fails here, not at registration after the merge. - -// A module a machine runs, removed from its source, fails until it is unassigned (ADR 0236). - -// **Issues 278 and 280**: a file in no held module's directory read as shared code, and a merge rebuilt the -// catalogue. A file is a module's only by being inside it — no build reads one outside — so it rebuilds -// nothing, and the gate says why; a merge that does rebuild widely is warned of. - -// **A delivery group is judged as one future state** (novox/hq ADR 0239): a catalogue change that needs a -// provision only another repository's change adds fails alone and passes composed with it; and a group -// whose other head breaks what the first needs fails, naming it. - -// catalogueMeshWithAnApp is catalogueMesh with an application built from a repository of its own, at its -// root, on the anchor. - -// busMesh is aMesh with a module on the bus on the laptop, its account issued as `module issue` issues -// one: minted, and its credential held as the module's own secret named broker. - -// **Issue 285**: every machine running a module on the bus failed to compose in the gate's store, with -// the change and without — the store held the module's credential and no account for it, which -// composition refuses (issue 203) — and the gate passed every change, "0 of 4 compose". The account the -// mesh issued is raised with its credential, so the machine composes in the gate as on the mesh. - -// **Issue 285**: a machine the mesh composes that the gate cannot raise as it is leaves the change judged -// against a machine that is not the mesh's — broken against broken, which passes whatever the change does. -// That is an error, never a pass. - -// A path the snapshot withheld is given a path of its own where an access or a place needs one: a bare -// "withheld" is no absolute path, and a machine whose setting composes on the mesh would not in the gate. -func TestAWithheldPathIsStoodInForByAPath(t *testing.T) { - got := standInPaths(map[string]any{ - "accesses": map[string]any{"races": "withheld", "films": "/media/films", "shows": "/withheld"}, - "places": map[string]any{"config": map[string]any{"path": "withheld", "owner": "1000:1000"}}, - "puid": 1000, - }) - accesses := got["accesses"].(map[string]any) - if accesses["races"] == accesses["shows"] || !strings.HasPrefix(accesses["races"].(string), "/") || - !strings.HasPrefix(accesses["shows"].(string), "/") || accesses["films"] != "/media/films" { - t.Errorf("accesses: %v", accesses) - } - place := got["places"].(map[string]any)["config"].(map[string]any) - if !strings.HasPrefix(place["path"].(string), "/") || place["owner"] != "1000:1000" || got["puid"] != 1000 { - t.Errorf("places: %v", got) - } -} - -// **Issue 286**: a check run by hand clones beside a change what the seat clones — one rule, read by the -// controller's ask and by the facts — and finds the repository it checks from its origin. -func TestACheckByHandClonesWhatTheSeatClones(t *testing.T) { - for dir, refs := range map[string]map[string]string{ - "mesh-catalog": {"mesh-catalog": "main"}, - "mesh-host": {"mesh-host": "c0ffee"}, - "mesh-controller": {"mesh-controller": "c0ffee", "mesh-controller-main": "main", "mesh-lab": "main", - "mesh-sdk": sdkPinnedByGoMod(t)}, - } { - got := besideRefs(dir, "c0ffee") - for d, ref := range refs { - if got[d] != ref { - t.Errorf("beside %s, %s is cloned at %q, not %q", dir, d, got[d], ref) - } - } - } - for owner, want := range map[string][3]string{ - "ssh://git@git.example:222/novox/mesh-host.git": {"novox", "mesh-host", "ssh://git@git.example:222/novox"}, - "git@git.example:novox/mesh-tools.git": {"novox", "mesh-tools", "git@git.example:novox"}, - "http://git.example/novox/hq": {"novox", "hq", "http://git.example/novox"}, - } { - o, r, p := ownerRepoOf(owner) - if [3]string{o, r, p} != want { - t.Errorf("%s reads as %s %s %s", owner, o, r, p) - } - } -} - -// sdkPinnedByGoMod is the SDK commit this tree's go.mod pins, read from the file rather than the build, so -// sdkPinned is held to what the repository says (novox/hq issue 449). -func sdkPinnedByGoMod(t *testing.T) string { - t.Helper() - raw, err := os.ReadFile(filepath.Join("..", "..", "go.mod")) - if err != nil { - t.Fatal(err) - } - for _, line := range strings.Split(string(raw), "\n") { - fields := strings.Fields(line) - if len(fields) >= 2 && fields[0] == sdkModule { - if m := pseudoCommit.FindStringSubmatch(fields[1]); m != nil { - return m[1] - } - return "go/" + fields[1] - } - } - t.Fatalf("go.mod requires no %s", sdkModule) - return "" -} - -// **A module one head of a group removes and another defines is a move** (novox/hq ADR 0420 §6, issue 1025): -// neither a second definition of one module name (issue 239) nor the removal of a module machines run -// (ADR 0236). Alone, each head is still refused. This lays the heads over the shelf as judgeChange does, -// which needs no database. - -// A group that moves a module machines run passes the gate whole, in either order of its heads, and says the -// module moved (novox/hq ADR 0420 §6, issue 1025); a head alone is still refused. Needs the test store. - -// **A module lives whole in one repository** (novox/hq ADR 0420 §1, issue 1019): an artifact whose build -// context names another repository is refused by the gate, and one naming its own passes it. - -// **A member of a delivery group judged alone sees a move** through the core clones beside its check -// (novox/hq ADR 0420 §6, issue 1043): the controller's own gate, with the catalogue beside having removed the -// module, is not refused for a second definition (issue 239); the catalogue's own gate, with the controller -// beside defining it, is not refused for a removal (ADR 0236). A clone beside at main finds no move. diff --git a/cmd/mesh-controller/new_module_approved_test.go b/cmd/mesh-controller/new_module_approved_test.go index 58ba371d..67725f52 100644 --- a/cmd/mesh-controller/new_module_approved_test.go +++ b/cmd/mesh-controller/new_module_approved_test.go @@ -116,7 +116,7 @@ func TestANewModuleIsRegisteredOnTheOperatorsApproveOfItsMergeAndRefusedOtherwis if c.built != nil { src = *c.built } - id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", src, byBuild("build-1"), "", "main") + id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", src, registrationAsk{Build: "build-1"}, "", "main") switch { case c.accepted && err != nil: t.Fatalf("%s: the Approved merge's new module was refused: %v", c.ac, err) @@ -157,7 +157,7 @@ func TestASecondNewModuleFromARepositoryIsJudgedAlongsideAndAsksNoWarrant(t *tes request: inventory.BuildRequest{ID: "build-2", Repository: "novox/mesh-delivery", Seat: "git", For: "build"}}} forms := &sourceForms{bases: map[string]string{}, base: func(string) string { return "http://forge.internal:20000" }} built := inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"} - if id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-watchman", built, byBuild("build-2"), "", "main"); err != nil || id == 0 { + if id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-watchman", built, registrationAsk{Build: "build-2"}, "", "main"); err != nil || id == 0 { t.Fatalf("1146/AC5: a second module from a repository the catalogue builds from was refused (id %d): %v", id, err) } } diff --git a/cmd/mesh-controller/new_modules.go b/cmd/mesh-controller/new_modules.go index 3fe4b5b6..faee9615 100644 --- a/cmd/mesh-controller/new_modules.go +++ b/cmd/mesh-controller/new_modules.go @@ -11,7 +11,6 @@ import ( "time" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/nats-io/nats.go/jetstream" "github.com/novox/mesh-controller/internal/builderkeys" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" @@ -210,10 +209,7 @@ func watchNewModules(ctx context.Context, open *stores) { fmt.Printf("new modules: the builder's keys are not watched: %v\n", err) return } - says := watchingSays{js: keys.JS} - says.watch(ctx) if err := builderkeys.Watch(ctx, keys.JS, func(module string) { - says.watch(ctx) wakeNewModule(ctx, open, module) }); err != nil { closeKeys() @@ -249,8 +245,9 @@ func wakeNewModule(ctx context.Context, open *stores, module string) { // ends only once the work its mesh started has. var background sync.WaitGroup -// inBackground runs work beside the caller, counted in background. -func inBackground(work func()) { +// inBackground runs work beside the caller, counted in background. A variable so a test of what a walk does around a +// take-in may run it in the caller's turn and need not wait for it. +var inBackground = func(work func()) { background.Add(1) go func() { defer background.Done() @@ -278,32 +275,3 @@ func newModulesOf(ctx context.Context, repository string) ([]string, error) { sort.Strings(out) return out, nil } - -// watchingSays watches the builder's bucket of instances once it is there (issue 1886): a change of what the builder says -// asks the look, which judges every open walk again. A builder from before R4e makes no such bucket; a write of the -// builder's keys asks again whether it is there now, so nothing waits on a timer. -type watchingSays struct { - js jetstream.JetStream - mu *sync.Mutex - on *bool -} - -func (w *watchingSays) watch(ctx context.Context) { - if w.mu == nil { - w.mu, w.on = &sync.Mutex{}, new(bool) - } - w.mu.Lock() - defer w.mu.Unlock() - if *w.on { - return - } - on, err := builderkeys.WatchSays(ctx, w.js, askToLook) - if err != nil { - fmt.Printf("plans: what the builder says it writes is not watched now: %v\n", err) - return - } - if on { - *w.on = true - askToLook() // what it says now may differ from what the last look read - } -} diff --git a/cmd/mesh-controller/outcome_off_the_loop_test.go b/cmd/mesh-controller/outcome_off_the_loop_test.go deleted file mode 100644 index 6b52e126..00000000 --- a/cmd/mesh-controller/outcome_off_the_loop_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package main - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/inventory" -) - -// heldElsewhere is the plans held by another act for longer than any test runs: waiting for them ends only with the -// context, and a look that does not wait is told at once that they are busy. -func heldElsewhere(ctx context.Context, wait bool) (func(), error) { - if !wait { - return nil, inventory.ErrPlansBusy - } - <-ctx.Done() - return nil, inventory.ErrPlansBusy -} - -// 1708/AC1: given the plans held by another act, when the event loop takes a build's outcome, then it returns at -// once, without waiting the plans' two minutes (on 2026-10-21 at 14:20:45 nodered's outcome held the loop until -// 14:22:45, and the operator was told the controller stopped listening). -func TestAnOutcomeDoesNotWaitForThePlansAnotherActHolds(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - start := time.Now() - release, err := holdPlansForOutcome(ctx, heldElsewhere) - if took := time.Since(start); took > time.Second { - t.Fatalf("taking an outcome waited %s for plans another act holds; the event loop takes nothing meanwhile", took) - } - if err == nil || release != nil { - t.Fatalf("plans another act holds were said to be held: release %v, err %v", release != nil, err) - } -} - -// 1708/AC4: given the plans free, when the event loop takes a build's outcome, then it holds them and marks the plan -// from the outcome directly, as before. -func TestAnOutcomeHoldsThePlansWhenTheyAreFree(t *testing.T) { - let := false - release, err := holdPlansForOutcome(t.Context(), func(context.Context, bool) (func(), error) { - return func() { let = true }, nil - }) - if err != nil || release == nil { - t.Fatalf("free plans were not held: %v", err) - } - release() - if !let { - t.Fatal("the hold given was not the one taken") - } -} - -// withLooker installs a looker that is never run, and answers it, so a test can read whether a look was asked. -func withLooker(t *testing.T) *looker { - t.Helper() - l := newLooker(func(context.Context) bool { return false }) - lookerMu.Lock() - before := theLooker - theLooker = l - lookerMu.Unlock() - t.Cleanup(func() { - lookerMu.Lock() - theLooker = before - lookerMu.Unlock() - }) - return l -} - -// 1708/AC2: given an outcome left to the build records because the plans were held, when it is left, then the look -// at the plans is asked for, so the records settle it once the holder lets go rather than at the next event. -func TestAnOutcomeLeftToTheRecordsAsksForTheLook(t *testing.T) { - l := withLooker(t) - leaveToRecords("nodered", "build-1708-ac2", "", "", inventory.ErrPlansBusy) - select { - case <-l.signal: - default: - t.Fatal("an outcome left to the build records asked for no look: the plan waits for whatever event comes next") - } -} - -// 1708/AC3: given an outcome left to the records that said what the build did (ADR 0350 §4), when the look settles -// the plan from the build records by that build's id, then the module keeps those words, once. -func TestTheRecordsSettleAnOutcomeWithWhatTheBuildDid(t *testing.T) { - withLooker(t) - asked := time.Date(2026, 10, 21, 12, 15, 0, 0, time.UTC) - plan := func() inventory.Plan { - return inventory.Plan{ID: "plan-1708", Tiers: [][]string{{"nodered"}}, - Modules: map[string]*inventory.PlanModule{"nodered": {State: "asked", AskedAt: &asked, Build: "build-1708-ac3"}}} - } - byID := map[string]inventory.Build{"build-1708-ac3": {ID: "build-1708-ac3", Module: "nodered", Commit: "4efac366", - At: asked.Add(5 * time.Minute)}} - leaveToRecords("nodered", "build-1708-ac3", "reused from build-1708-check", "", inventory.ErrPlansBusy) - - p := plan() - if !settleFromRecords(&p, p.Tiers[0], nil, byID) || p.Modules["nodered"].State != "built" { - t.Fatalf("the records did not settle the outcome: %+v", p.Modules["nodered"]) - } - if got := p.Modules["nodered"].Made; got != "reused from build-1708-check" { - t.Fatalf("settled from the records, the module says %q of what its build did, not the outcome's words", got) - } - again := plan() - settleFromRecords(&again, again.Tiers[0], nil, byID) - if got := again.Modules["nodered"].Made; got != "" { - t.Fatalf("an outcome's words were kept for a second settle: %q", got) - } -} - -// 1708/AC5: given a serving controller (its look at the plans running), when an outcome was taken into the plans, -// then moving them (a release's sends, which may retry for minutes) is left to the look, off the event loop -// (2026-10-21 14:28:40 to 14:32:10: the loop sent okioki's release to g14 after mesh-delivery-builder's outcome). -func TestAnOutcomeLeavesMovingThePlansToTheLook(t *testing.T) { - l := withLooker(t) - moved := false - advanceAfterOutcome(func() { moved = true }) - if moved { - t.Fatal("the plans were moved on the event loop after an outcome, where a send that retries holds every message") - } - select { - case <-l.signal: - default: - t.Fatal("the look was not asked to move the plans after an outcome") - } -} - -// 1708/AC5: given no look running (a command, which has no event loop), when an outcome was taken, then the plans are -// moved at once, as before. -func TestAnOutcomeMovesThePlansWhereNoLookRuns(t *testing.T) { - lookerMu.Lock() - before := theLooker - theLooker = nil - lookerMu.Unlock() - t.Cleanup(func() { - lookerMu.Lock() - theLooker = before - lookerMu.Unlock() - }) - moved := false - advanceAfterOutcome(func() { moved = true }) - if !moved { - t.Fatal("with no look running, an outcome left the plans unmoved") - } -} - -// 1708/AC6: given a registration the mesh refused (its build recorded as worked, the outcome failed), left to the -// records because the plans were held, when the look settles the plan from the records, then the module and the plan -// are failed, never built: settled as built, the tier would be sent the version from before (reviewer-1708@shanks). -func TestARefusedRegistrationLeftToTheRecordsFailsThePlan(t *testing.T) { - withLooker(t) - asked := time.Date(2026, 10, 21, 12, 15, 0, 0, time.UTC) - p := inventory.Plan{ID: "plan-1708-ac6", State: inventory.PlanBuilding, Tiers: [][]string{{"nodered"}}, - Modules: map[string]*inventory.PlanModule{"nodered": {State: "asked", AskedAt: &asked, Build: "build-1708-ac6"}}} - byID := map[string]inventory.Build{"build-1708-ac6": {ID: "build-1708-ac6", Module: "nodered", Commit: "4efac366", - At: asked.Add(5 * time.Minute)}} - leaveToRecords("nodered", "build-1708-ac6", "", "the mesh does not register it: a port is taken", inventory.ErrPlansBusy) - - overlayRefusals(nil, byID, refusalsKeptFor([]string{"build-1708-ac6"})) - settleFromRecords(&p, p.Tiers[0], nil, byID) - if s := p.Modules["nodered"]; s.State != "failed" || p.State != inventory.PlanFailed { - t.Fatalf("a refused registration left to the records settled as %q, the plan %q", s.State, p.State) - } - if !strings.Contains(p.Modules["nodered"].Why, "a port is taken") { - t.Fatalf("the refusal's words were lost: %q", p.Modules["nodered"].Why) - } -} - -// 1708/AC6: a refusal kept in the store (a controller replaced before the settle) fails the record as one kept in -// the process does. -func TestARefusalFromTheStoreFailsTheRecord(t *testing.T) { - recorded := map[string][]inventory.Build{"nodered": {{ID: "b-store", Module: "nodered"}, {ID: "b-other", Module: "nodered"}}} - byID := map[string]inventory.Build{"b-store": {ID: "b-store", Module: "nodered"}} - overlayRefusals(recorded, byID, map[string]string{"b-store": "not registered"}) - if recorded["nodered"][0].Worked() || byID["b-store"].Worked() { - t.Fatal("a build whose registration was refused still reads as worked") - } - if !recorded["nodered"][1].Worked() { - t.Fatal("a build nobody refused was failed") - } -} - -// 1708/AC7: given the records settle a module as deleted at its source, or fail the plan, when the look settles, then -// it does what taking the outcome directly does: the deleted module is named to be forgotten, and the plan's failure -// is said (reviewer-1708@shanks). -func TestSettlingFromTheRecordsDoesWhatTakingTheOutcomeDoes(t *testing.T) { - p := inventory.Plan{State: inventory.PlanFailed, Modules: map[string]*inventory.PlanModule{ - "gone": {State: planDeleted}, "x": {State: "failed"}, "y": {State: "built"}}} - before := map[string]string{"gone": "asked", "x": "asked", "y": "built"} - deleted, failed := settledEffects(before, &p, inventory.PlanBuilding) - if len(deleted) != 1 || deleted[0] != "gone" { - t.Fatalf("the module the records found deleted is not forgotten: %v", deleted) - } - if !failed { - t.Fatal("the plan the records failed is not said as failed") - } - if d, f := settledEffects(map[string]string{"gone": planDeleted}, &p, inventory.PlanFailed); len(d) != 0 || f { - t.Fatalf("what was deleted or failed before the settle is acted on again: %v %v", d, f) - } -} diff --git a/cmd/mesh-controller/pending.go b/cmd/mesh-controller/pending.go index 0a48d957..b2c63231 100644 --- a/cmd/mesh-controller/pending.go +++ b/cmd/mesh-controller/pending.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "sort" "strings" "time" @@ -14,7 +13,6 @@ import ( "github.com/novox/mesh-controller/internal/builderkeys" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // A module not registered yet, and the assignment that waits for it (novox/hq issue 325, ADR 0261). @@ -74,15 +72,6 @@ type assignOptions struct { Build bool } -// recordBuildRequest keeps a build request so `assign` can find it, once it is asked. Said, never fatal: the -// build was asked. -func recordBuildRequest(ctx context.Context, inv *inventory.Inventory, r inventory.BuildRequest) { - if err := inv.RecordBuildRequest(ctx, r); err != nil { - fmt.Fprintf(os.Stderr, "build %s was asked, and could not be kept as a build request, so `assign` "+ - "will not know it is coming: %v\n", r.ID, err) - } -} - // The cases of a module the catalogue does not hold (novox/hq issue 325). const ( unknownModule = iota @@ -474,40 +463,6 @@ func expirePending(ctx context.Context, inv *inventory.Inventory, p inventory.Pe return note } -// settlePendingOnBuild is a build's outcome reaching the assignments waiting for it, wherever the outcome is -// taken in (novox/hq issue 325): a module registered makes every assignment pending on it; a build of a -// pending assignment that failed, or was not registered, ends it with why. Returns what was said. -func settlePendingOnBuild(ctx context.Context, open *stores, result link.BuildResult, module string, takeErr error) []string { - inv := open.inventory - waiting, err := inv.Pending(ctx, time.Time{}) - if err != nil { - return []string{fmt.Sprintf("the pending assignments could not be read after build %s: %v", result.ID, err)} - } - registered := module != "" && (takeErr == nil || errors.Is(takeErr, inventory.ErrSuperseded)) - var said []string - for _, p := range waiting { - switch { - case registered && p.Module == module: - if line := applyPending(ctx, open, p, result.ID); line != "" { - said = append(said, line) - } - case !registered && p.Build == result.ID: - why := firstLine(result.Failed) - if why == "" && takeErr != nil { - why = firstLine(takeErr.Error()) - } - what := fmt.Sprintf("build %s of %s failed: %s", result.ID, orRoot(p.Path), why) - if result.Failed == "" { - what = fmt.Sprintf("build %s of %s was recorded and not registered: %s", result.ID, orRoot(p.Path), why) - } - if line := expirePending(ctx, inv, p, what); line != "" { - said = append(said, line) - } - } - } - return said -} - // settlingPending is the controller's tick over the pending assignments (ADR 0261), every settleEvery until // the context ends. What it does is printed to the controller's log, kept in each row's note (which `status` // lists for a day), and raised as a condition for an assignment that was not made. diff --git a/cmd/mesh-controller/pending_integration_test.go b/cmd/mesh-controller/pending_integration_test.go index 3e1b4780..1f2b246a 100644 --- a/cmd/mesh-controller/pending_integration_test.go +++ b/cmd/mesh-controller/pending_integration_test.go @@ -5,7 +5,6 @@ package main import ( "encoding/json" "errors" - "fmt" "slices" "strings" "testing" @@ -567,34 +566,6 @@ func TestAStaleClaimIsSettledFromWhatTheMeshHolds(t *testing.T) { } } -func TestABuildNoLongerWaitedForIsStillInFlight(t *testing.T) { - open := aCatalogueMesh(t) - ctx := t.Context() - inv := open.inventory - for _, id := range []string{"build-timeout", "build-nothandedover"} { - dir := "modules/" + strings.TrimPrefix(id, "build-") - if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: id, Repository: "novox/mesh-catalog", - Seat: "git", Path: dir, Ref: "main", For: "build"}); err != nil { - t.Fatal(err) - } - } - markWaitFailed(ctx, "build-timeout", errors.New("no build machine answered within 10m0s")) - markWaitFailed(ctx, "build-nothandedover", fmt.Errorf("%w: cannot submit a build: nats: timeout", link.ErrNotHandedOver)) - - said, err := assign(ctx, open, "laptop", "timeout") - if err != nil || !strings.Contains(said, "being built") || !strings.Contains(said, "kept as pending") { - t.Fatalf("a build no longer waited for: %q %v", said, err) - } - if _, err := assign(ctx, open, "laptop", "nothandedover"); err == nil || !strings.Contains(err.Error(), "was not handed over") { - t.Fatalf("a build never handed over: %v", err) - } - settlePending(ctx, open, time.Now().Add(buildRequestBound+time.Minute)) - rows, _ := inv.PendingFor(ctx, "laptop", "timeout") - if len(rows) != 1 || rows[0].State != inventory.PendingExpired || !strings.Contains(rows[0].Note, "its asker stopped waiting") { - t.Fatalf("past the bound: %+v", rows) - } -} - // A10 (ADR 0501 §6; issue 1851): an assignment of a module the indexer holds and the catalogue does not yet waits for // the builder's done key of it, asking nothing, and is made when that key registers the module. func TestAnAssignmentWaitsForTheKeyAndAsksNothing(t *testing.T) { diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 1a954534..184e5d69 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -1084,7 +1084,7 @@ func unansweredWords(l stalledLine, o conditions.Observation) (headline, explana } // uncheckedWaitWords are the merge checks an unanswered line waits on, as the operator reads them: from the checks -// given as data, each time in the controller's local zone, as every other time in a message — "mesh/merge-gate +// given as data, each time in the controller's local zone, as every other time in a message — "mesh/check // pending since 02:11" — never the UTC time inside mesh-delivery's finished words, which cannot be said again in // another zone (novox/hq issue 443). A line from a mesh-delivery that gives no such data is said by its words. func uncheckedWaitWords(l stalledLine) []string { diff --git a/cmd/mesh-controller/plan_clock_test.go b/cmd/mesh-controller/plan_clock_test.go index 36073456..7de204d1 100644 --- a/cmd/mesh-controller/plan_clock_test.go +++ b/cmd/mesh-controller/plan_clock_test.go @@ -22,11 +22,11 @@ func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) { if !strings.Contains(line, "building for 40m0s") || !strings.Contains(line, "LATE") { t.Fatalf("a plan in its tier for 40m, saved 3s ago, reads %q", line) } - st := planStatuses([]inventory.Plan{p}, now, pauseView{}, nil)[0] + st := planStatuses([]inventory.Plan{p}, now, nil)[0] if !st.Late || !st.Since.Equal(p.TierEntered) { t.Errorf("status --json says %+v", st) } - if _, late := openPlans([]inventory.Plan{p}, now, pauseView{}, nil); late != 1 { + if _, late := openPlans([]inventory.Plan{p}, now, nil); late != 1 { t.Errorf("status counted %d late", late) } @@ -42,7 +42,7 @@ func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) { q := p q.TierEntered = now.Add(-in) bound := bounds.of(q.Repository) - late := strings.Contains(planLineWith(q, now, pauseView{}, bound), "LATE") + late := strings.Contains(planLineWith(q, now, bound), "LATE") stalled := len(watchPlans(&signalFacts{now: now, plans: []planFacts{{id: q.ID, repository: q.Repository, commit: q.Commit, tiers: 1, entered: inTierSince(q), bound: bound}}})) == 1 if late != stalled || late != (in > bound) { @@ -65,7 +65,7 @@ func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) { if line := planLine(waiting, now); !strings.Contains(line, "for 40m0s") || strings.Contains(line, "LATE") { t.Errorf("a walk waiting 40m reads %q", line) } - if st := planStatuses([]inventory.Plan{waiting}, now, pauseView{}, nil)[0]; st.Late { + if st := planStatuses([]inventory.Plan{waiting}, now, nil)[0]; st.Late { t.Errorf("a waiting walk is called late: %+v", st) } let := now.Add(-5 * time.Minute) @@ -99,7 +99,7 @@ func TestAPlanLineSaysTheRefusalOnceItsTierIsBuilt(t *testing.T) { if !strings.Contains(line, "a build waits there for its gate: novox") || strings.Contains(line, "building for") { t.Errorf("920/AC1: a plan with its tier built and a refusal reads %q", line) } - st := planStatuses([]inventory.Plan{p}, now, pauseView{}, nil) + st := planStatuses([]inventory.Plan{p}, now, nil) if len(st) != 1 || !st[0].Late { t.Errorf("920/AC1: status: %+v", st) } diff --git a/cmd/mesh-controller/plan_keys.go b/cmd/mesh-controller/plan_keys.go index 1bdb1bcc..f867ad6b 100644 --- a/cmd/mesh-controller/plan_keys.go +++ b/cmd/mesh-controller/plan_keys.go @@ -9,7 +9,6 @@ import ( sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/builderkeys" "github.com/novox/mesh-controller/internal/inventory" ) @@ -17,64 +16,19 @@ import ( // A walk takes its modules from the builder's done keys (novox/hq ADR 0503 §6, ADR 0501 §1, ADR 0498 §8; issue 1851, A6 // and A7). // -// From R4e a walk asks build-agent for no module but the builder's own (ADR 0453 §5). A module of its tier is marked +// A walk asks no build of anybody (R4g; ADR 0453 §5). A module of its tier is marked // taken from a key (PlanModule.Key) and settled by the look: its fingerprint is read from the indexer's trunk value // written for the commit the walk carries of its repository; the builder's key of that fingerprint, done with every // artifact referenced, is taken in (recorded and registered, off the plans' hold) and the module is built; a key not done // yet, or waiting for a reference, holds the tier and says so; a failed key fails the walk in the builder's own words. // **No timer**: the builder's and the indexer's writes wake the look (builderkeys.Watch, watchNewModules). -// isBuildersOwn says a module is build-agent's own: asked of build-agent on a merge and taken from its build record, never -// from the builder's key, until R4g (ADR 0453 §5, ADR 0498 §7, ADR 0520 §1). -func isBuildersOwn(module string) bool { return buildersOwn(module) } - -// buildAgentsOwn are those modules, named (ADR 0520 §1): the builder's; build-agent itself, whose image's context is another -// repository's; and mesh-tools, the toolchain image build-agent's builds stand on. The builder writes no key of any of them, -// and a walk waiting for one would hold its tier for ever. mesh-tools-go is not among them: it builds from a Dockerfile in -// its own directory on a pinned base, and has its key from mesh-build-image. -// -// **And the parts that write references** (ADR 0522 §6, issue 1886): the build modules push and answer each reference, -// and the builder writes it into the key, so none of them is ever taken from a key they write themselves. Until R4g -// build-agent is the way they are delivered whatever the builder says. -var buildAgentsOwn = map[string]bool{sdkdelivery.BuilderModule: true, builder.AgentModule: true, "mesh-tools": true, - "mesh-build-go": true, "mesh-build-node": true, "mesh-build-image": true} - -// buildersOwn is isBuildersOwn's rule: a variable only so a test of the asks left (their bus waits, their plan key) may -// name modules of its own as build-agent's. -var buildersOwn = func(module string) bool { return buildAgentsOwn[module] } - // planKeyReads is what a walk reads of the indexer and the builder (builderkeys.Reader, or a test's). type planKeyReads interface { ModuleAt(ctx context.Context, module, commit string) (sdkdelivery.Module, error) Build(ctx context.Context, module, fp string) (builderkeys.Key, error) } -// builderSayKey carries, on an ask's context, the builder's say the walk read before asking (withBuilderSay). -type builderSayKey struct{} - -// withBuilderSay hands the say a walk read to the ask's guard, so the guard never reads it a second time. -func withBuilderSay(ctx context.Context, says bool) context.Context { - return context.WithValue(ctx, builderSayKey{}, says) -} - -// builderSayOf is the say a walk handed the ask, and whether it handed one. -func builderSayOf(ctx context.Context) (says, handed bool) { - says, handed = ctx.Value(builderSayKey{}).(bool) - return says, handed -} - -// builderSaysReferences is whether a running builder instance says, in its own bucket of instances, that the keys it -// writes name references (issue 1886). Until one does, a walk asks build-agent for every module, as before R4e: the -// keys the running builder writes could never be delivered from. A variable so a test needs no bus. -var builderSaysReferences = func(ctx context.Context) (bool, error) { - keys, closeKeys, err := openBuilderKeys() - if err != nil { - return false, err - } - defer closeKeys() - return keys.SaysReferences(ctx) -} - // openPlanKeys opens what a walk reads of the indexer and the builder. A variable so a test needs no bus. var openPlanKeys = func() (planKeyReads, func(), error) { r, closeKeys, err := openBuilderKeys() @@ -112,10 +66,13 @@ var keyTakes = struct { // the buckets and the records; a done key not recorded yet is taken in off the plans' hold, and its end asks the look. func settleFromKeys(ctx context.Context, open *stores, keys planKeyReads, p *inventory.Plan, tier []string) (bool, error) { return settleKeys(ctx, open.inventory, keys, p, tier, func(module string, trunk sdkdelivery.Module, id string) { - inBackground(func() { takeKeyOffTheHold(context.WithoutCancel(ctx), open, module, trunk, id) }) + inBackground(func() { takeKeyIn(context.WithoutCancel(ctx), open, module, trunk, id) }) }) } +// takeKeyIn is how a look takes a done key in; a variable so a test of what a walk does around it needs no builder. +var takeKeyIn = takeKeyOffTheHold + // keyRecords is what settling from keys reads of the records (*inventory.Inventory, or a test's). type keyRecords interface { BuildByID(ctx context.Context, id string) (inventory.Build, bool, error) @@ -133,20 +90,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven changed = true } } - // One say for the ask and the settle (builderSaysReferences): two sources that differ hand a module back and take it - // from its key again at every turn of the look, which then never ends. - says, err := builderSaysReferences(ctx) - if err != nil { - return changed, fmt.Errorf("whether the builder writes references cannot be read: %w", err) - } - // **A walk never waits on a key the running builder cannot reference** (issue 1886): while no builder says it writes - // references, a module that waits on its key, or whose key failed, is handed back unasked, and the look asks it of - // build-agent. A done key with every reference is still taken. - toAgent := func(s *inventory.PlanModule, why string) { - s.State, s.Key, s.AskedAt, s.KeyAt, s.Build, s.Fingerprint = "", false, nil, "", "", "" - s.Why = "the builder does not say it writes references, so build-agent is asked (" + why + ")" - changed = true - } for _, m := range tier { s := p.Modules[m] if s == nil || !s.Key || s.State != "asked" { @@ -154,10 +97,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven } trunk, err := keys.ModuleAt(ctx, m, s.KeyAt) if errors.Is(err, builderkeys.ErrNoValue) { - if !says { - toAgent(s, "the indexer holds no value of it") - continue - } set(s, fmt.Sprintf("waits for the indexer's value of %s at %s", m, orNow(s.KeyAt))) continue } @@ -169,10 +108,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven if why == "" { why = "the indexer wrote no fingerprint of the new form" } - if !says { - toAgent(s, why) - continue - } set(s, fmt.Sprintf("waits: %s at %s has no fingerprint to take a build by: %s", m, orNow(s.KeyAt), why)) continue } @@ -184,9 +119,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven return changed, err } switch { - case !says && (k.Failed() || !k.Done() || len(k.Unreferenced()) > 0): - toAgent(s, k.Says()) - continue case k.Failed(): // **A failed key fails the walk in the builder's own words** (`digests differ:`, `not stored:`, `not pinned:`, // `version taken:`), and a rebuild of it is a new revision the next walk takes. diff --git a/cmd/mesh-controller/plan_keys_test.go b/cmd/mesh-controller/plan_keys_test.go index 29190f32..3fe15089 100644 --- a/cmd/mesh-controller/plan_keys_test.go +++ b/cmd/mesh-controller/plan_keys_test.go @@ -73,12 +73,6 @@ func aWalkOfOne(module string) (*inventory.Plan, map[string]inventory.Entry) { // A6 (ADR 0503 §6, ADR 0498 check §7): a walk whose module has a done key goes asked to built with no build asked: the // ask stub is never called, the key is taken in once, and `plans` says it was taken from the builder's key. func TestAWalkTakesADoneKeyAndAsksNoBuild(t *testing.T) { - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - t.Fatal("a walk asked build-agent for a module that is not the builder's own") - return "", nil - } - t.Cleanup(func() { askABuild = was }) p, byName := aWalkOfOne("sudo") askModule(t.Context(), p, "sudo", byName) s := p.Modules["sudo"] @@ -118,20 +112,11 @@ func TestAWalkTakesADoneKeyAndAsksNoBuild(t *testing.T) { if s.State != "built" || s.Build != id || !strings.Contains(s.Made, "taken from the builder's key") { t.Fatalf("the module is not built from its key: %+v", s) } - if takesOutcome(*p, &inventory.PlanModule{Key: true, State: "asked", AskedAt: s.AskedAt}, time.Now(), "build-1") { - t.Fatal("a build-agent outcome answers a module taken from a key") - } } // A7: a key building waits and says so, then moves on when done, with no timer and no ask; a failed key fails the walk // in the builder's words; a value the indexer has not written yet waits, named. func TestAWalkWaitsForAKeyAndFailsWithItsWords(t *testing.T) { - asksOfNone := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - t.Fatal("a walk asked a build") - return "", nil - } - t.Cleanup(func() { askABuild = asksOfNone }) t.Cleanup(func() { keyTakes.Lock() keyTakes.running, keyTakes.waits, keyTakes.taken = map[string]bool{}, map[string]string{}, map[string]bool{} diff --git a/cmd/mesh-controller/plan_looks.go b/cmd/mesh-controller/plan_looks.go index 855d6e02..d8d84206 100644 --- a/cmd/mesh-controller/plan_looks.go +++ b/cmd/mesh-controller/plan_looks.go @@ -136,18 +136,6 @@ func askToLook() { } } -// askedToLook asks the serving controller's look at the plans, and says whether there was one to ask. -func askedToLook() bool { - lookerMu.RLock() - l := theLooker - lookerMu.RUnlock() - if l == nil { - return false - } - l.ask() - return true -} - // startPlanLooks starts the look at the open plans, and asks for the first: the controller took the lease, and a // plan left by a replaced controller resumes from there (ADR 0406 §6). It replaces the rollout's 30 second ticker. // It answers a channel that is closed once the look has returned after the context ended, for a test that restores what @@ -206,10 +194,6 @@ func servingPlanSaved(ctx context.Context, bus link.Bus, open *stores) func(*inv return func(_ *inventory.Inventory, p inventory.Plan) { sayPlanMoved(ctx, bus, open.inventory, p) planKept(p) - keepBuildingKey(ctx, p) - if p.WalkSaid == inventory.WalkEnded { - go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p) - } } } diff --git a/cmd/mesh-controller/plan_retry.go b/cmd/mesh-controller/plan_retry.go index 7e846f77..f7bf2a41 100644 --- a/cmd/mesh-controller/plan_retry.go +++ b/cmd/mesh-controller/plan_retry.go @@ -3,14 +3,12 @@ package main import ( "context" "fmt" - "os" "slices" "sort" "strings" "time" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // A plan follows what is done to the build queue (novox/hq ADR 0219). @@ -26,106 +24,6 @@ import ( // beside it — beside it, the plan would either ask it again or stay failed on an outcome the // rebuild has already replaced. -// pauseView is whether the build seat takes work: the holders that said they are paused, and -// whether that is every holder. -type pauseView struct { - Nodes []string - All bool -} - -// pausedWaiting is what a plan waits on when the build seat is paused under it, or false: a plan -// building, whose current tier has an ask outstanding, while every holder of the seat is paused. -func pausedWaiting(p inventory.Plan, pause pauseView, now time.Time) (string, bool) { - if p.State != inventory.PlanBuilding || !pause.All || len(pause.Nodes) == 0 || p.Tier >= len(p.Tiers) { - return "", false - } - var asked *time.Time - for _, m := range p.Tiers[p.Tier] { - if s := p.Modules[m]; s != nil && s.State == "asked" && s.AskedAt != nil { - if asked == nil || s.AskedAt.Before(*asked) { - asked = s.AskedAt - } - } - } - if asked == nil { - return "", false - } - waited := now.Sub(*asked) - said := fmt.Sprintf("waiting: the build seat is paused on %s (asked %s ago)", - strings.Join(pause.Nodes, ", "), waited.Round(time.Second)) - // Not late — a pause is a decision — but a pause forgotten is a plan that never moves, so one held - // longer than a day is named. - if waited > pausedTooLong { - said += " — PAUSED OVER A DAY: `resume` takes builds again" - } - return said, true -} - -// pausedTooLong is how long a plan may wait on a paused build seat before it says the pause is long. -const pausedTooLong = 24 * time.Hour - -// awaitsABuild is whether any open plan has an ask outstanding in its current tier — the only case -// where the seat being paused changes what a plan says. -func awaitsABuild(plans []inventory.Plan) bool { - for _, p := range plans { - if p.State != inventory.PlanBuilding || p.Tier >= len(p.Tiers) { - continue - } - for _, m := range p.Tiers[p.Tier] { - if s := p.Modules[m]; s != nil && s.State == "asked" { - return true - } - } - } - return false -} - -// buildSeatPause reads whether the build seat's holders take work, from what each last said on the -// bus (link.HolderState) — read only when a plan waits on a build, so a mesh with nothing building -// does not dial the bus to say so. Anything unreadable is said and read as not paused: a plan then -// reads as late, which is what it said before this existed. -func buildSeatPause(ctx context.Context, inv *inventory.Inventory, plans []inventory.Plan) pauseView { - if !awaitsABuild(plans) { - return pauseView{} - } - entries, err := inv.Catalogued(ctx) - if err != nil { - return pauseView{} - } - seat := buildSeatAmong(entries) - holders := holdersAmong(entries, seat) - if len(holders) == 0 { - return pauseView{} - } - // On the serving controller's own connection when this is it: a watchdog tick while a walk waits for a - // build dialled one every 30 seconds (novox/hq issue 327). - js, err := aBus() - if err != nil { - fmt.Fprintf(os.Stderr, "could not reach the bus to read whether the build seat is paused: %v\n", err) - return pauseView{} - } - defer js.Close() - said, err := link.PausedSaid(js, seat, holders) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read whether the build seat is paused: %v\n", err) - return pauseView{} - } - return pauseOf(holders, said) -} - -// pauseOf is the view from what each holder said. -func pauseOf(holders []string, said map[string]link.HolderState) pauseView { - var v pauseView - for _, n := range holders { - if said[n].Paused { - v.Nodes = append(v.Nodes, n) - } - } - sort.Strings(v.Nodes) - v.All = len(holders) > 0 && len(v.Nodes) == len(holders) - return v -} - // failedIn is the modules of a plan's current tier that failed to build, sorted. func failedIn(p inventory.Plan) []string { if p.Tier >= len(p.Tiers) { @@ -684,20 +582,10 @@ func holdingABuild(p inventory.Plan) []string { return out } -// resumeStopped sets a walk stopped by hand building again at the tier it stopped at, its stop cleared and its asks kept but -// build-agent's own (issue 1886): the next look carries it on as any walk — a build done waits for its reference, and one done on the +// resumeStopped sets a walk stopped by hand building again at the tier it stopped at, its stop cleared and its asks kept +// (issue 1886): the next look carries it on as any walk — a build done waits for its reference, and one done on the // builder's key is handed on (novox/hq ADR 0522 §3). Its note keeps who stopped it and that a person resumed it. func resumeStopped(p *inventory.Plan) string { - // **Build-agent's own are asked of build-agent** (novox/hq ADR 0522 §6): one marked taken from a key it writes itself is - // set unasked, and the look asks the rest of a partly asked tier (unaskedOf), of build-agent, whatever the builder says. - var again []string - for _, m := range p.Tiers[p.Tier] { - if s := p.Modules[m]; s != nil && s.State == "asked" && s.Key && isBuildersOwn(m) { - s.State, s.Key, s.AskedAt, s.Build, s.Why = "", false, nil, "", "" - again = append(again, m) - } - } - sort.Strings(again) asked, unsent, unasked := waitingIn(*p) later := 0 for i := p.Tier + 1; i < len(p.Tiers); i++ { @@ -706,9 +594,6 @@ func resumeStopped(p *inventory.Plan) string { by, why := p.Delivery.Stopped, p.Delivery.StoppedWhy waits := fmt.Sprintf("%d asked, %d built and not sent, %d not yet asked in it; %d not yet asked in the tiers after", asked, unsent, unasked, later) - if len(again) > 0 { - waits += "; " + strings.Join(again, ", ") + " set unasked, for the look to ask of build-agent (its own, ADR 0522 §6)" - } p.State = inventory.PlanBuilding goesOnAgain(p) p.Delivery.Kept = nil diff --git a/cmd/mesh-controller/plan_retry_stopped_test.go b/cmd/mesh-controller/plan_retry_stopped_test.go index d8618f85..a4552dfd 100644 --- a/cmd/mesh-controller/plan_retry_stopped_test.go +++ b/cmd/mesh-controller/plan_retry_stopped_test.go @@ -1,7 +1,6 @@ package main import ( - "slices" "strings" "testing" "time" @@ -27,7 +26,7 @@ func TestRetryResumesAWalkStoppedByHand(t *testing.T) { p.Modules[m] = &inventory.PlanModule{State: "built", BuiltAt: &sent, SentAt: &sent} } var tier1 []string - for _, m := range []string{"build-agent", "mesh-tools"} { + for _, m := range []string{"mesh-tools-go", "mesh-tools"} { tier1 = append(tier1, m) p.Modules[m] = &inventory.PlanModule{State: "built", AskedAt: &sent, BuiltAt: &sent, Build: "build-" + m} } @@ -58,22 +57,12 @@ func TestRetryResumesAWalkStoppedByHand(t *testing.T) { } for _, m := range onKeys { s := p.Modules[m] - if isBuildersOwn(m) { - // Build-agent's own are asked of build-agent whatever the builder says (ADR 0522 §6): set unasked, for the look. - if s.State != "" || s.Key || s.Build != "" || s.AskedAt != nil { - t.Errorf("%s, build-agent's own, was left on the builder's key: %+v", m, s) - } - if !slices.Contains(unaskedOf(p), m) { - t.Errorf("%s is not among what the look asks next: %v", m, unaskedOf(p)) - } - continue - } if s.State != "asked" || !s.Key || s.Build != "build-"+m { t.Errorf("%s's ask was not kept for the look to hand on: %+v", m, s) } } - for _, w := range []string{"plan-r4e", "tier 2 of 3", "5 asked", "2 built and not sent", "3 not yet asked in it", - "3 not yet asked in the tiers after", "mesh-build-go, mesh-build-image, mesh-build-node set unasked"} { + for _, w := range []string{"plan-r4e", "tier 2 of 3", "8 asked", "2 built and not sent", "0 not yet asked in it", + "3 not yet asked in the tiers after"} { if !strings.Contains(said, w) { t.Errorf("the answer %q does not say %q", said, w) } @@ -109,8 +98,8 @@ func TestRetryResumesAWalkStoppedByHand(t *testing.T) { } // A newer plan holding a build of its tier not yet sent refuses it: resuming would send the older build over the newer. newerBuilt := inventory.Plan{ID: "plan-agent", Repository: "novox/mesh-controller", Branch: "main", Commit: "d00dfeed", - Created: at, State: inventory.PlanDone, Tiers: [][]string{{"build-agent"}}, - Modules: map[string]*inventory.PlanModule{"build-agent": {State: "built"}}} + Created: at, State: inventory.PlanDone, Tiers: [][]string{{"mesh-tools"}}, + Modules: map[string]*inventory.PlanModule{"mesh-tools": {State: "built"}}} if err := retryRefusal(stopped, []inventory.Plan{stopped, newerBuilt}, ownPathOf(nil)); err == nil || !strings.Contains(err.Error(), "older build") { t.Errorf("a stopped walk was resumed over a newer plan of a build it has not sent: %v", err) diff --git a/cmd/mesh-controller/planner_rules_test.go b/cmd/mesh-controller/planner_rules_test.go index a002ee5e..4e14a594 100644 --- a/cmd/mesh-controller/planner_rules_test.go +++ b/cmd/mesh-controller/planner_rules_test.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "fmt" "math/rand/v2" "sort" @@ -9,7 +8,6 @@ import ( "testing" "time" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -589,46 +587,6 @@ func describe(entries []inventory.Entry) []string { return out } -// **The merge gate reads the build sources the snapshot carries** (novox/hq ADR 0267): the gate's plan of a -// change is the merge handler's, so a snapshot taken by a controller that records build sources narrows the -// gate's plan as it narrows the merge's; one without them reads every module as before. -func TestTheGatePlansFromTheBuildSourcesTheSnapshotCarries(t *testing.T) { - manifest := func(name string) json.RawMessage { return json.RawMessage(`{"module":"` + name + `","version":"1"}`) } - facts := snapshot.Facts{Modules: []snapshot.Module{ - {Name: "mesh-controller", Repository: "novox/mesh-controller", Manifest: manifest("mesh-controller"), - Sources: []snapshot.BuildSource{{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/broker/"}}}}, - {Name: "route-proxy", Repository: "novox/mesh-catalog", Path: "modules/route-proxy", Manifest: manifest("route-proxy"), - Reads: []string{"novox/mesh-controller"}, - Sources: []snapshot.BuildSource{{Repository: "novox/mesh-controller", Paths: []string{"modules/route-proxy/cmd/route-proxy/", "internal/broker/"}}, - {Own: true, Paths: []string{"modules/route-proxy/module.json"}}}}, - }} - for paths, want := range map[string]string{ - "cmd/mesh-controller/main.go": "mesh-controller", - "modules/route-proxy/cmd/route-proxy/main.go": "route-proxy", - "internal/broker/broker.go": "mesh-controller,route-proxy", - "README.md": "", - } { - r, err := reachOfChange(facts, "novox/mesh-controller", []string{paths}, "") - if err != nil { - t.Fatal(err) - } - if got := tiered(r.Plan.Tiers); got != want { - t.Errorf("%s: the gate planned %q, wanted %q", paths, got, want) - } - } - // A snapshot without build sources: as before. - for i := range facts.Modules { - facts.Modules[i].Sources = nil - } - r, err := reachOfChange(facts, "novox/mesh-controller", []string{"README.md"}, "") - if err != nil { - t.Fatal(err) - } - if got := tiered(r.Plan.Tiers); got != "mesh-controller,route-proxy" { - t.Errorf("a snapshot without build sources: the gate planned %q for a README", got) - } -} - // **A plan says why each module is in it** (novox/hq ADR 0267, issue 363): the files of its build source the // merge changed, or why it is read whole — never that it packages a repository as though that moved it. func TestAPlanSaysWhyEachModuleIsInIt(t *testing.T) { diff --git a/cmd/mesh-controller/plans_building.go b/cmd/mesh-controller/plans_building.go deleted file mode 100644 index 46ba06c3..00000000 --- a/cmd/mesh-controller/plans_building.go +++ /dev/null @@ -1,238 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "sync/atomic" - "time" - - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// The plans building (novox/hq ADR 0483 clause 2, issue 1687). -// -// **A key per delivery plan in its build phase, so that the build seat takes no new check while a delivery builds.** The phase -// runs from the ask of the plan's first build until every build of its last tier is built, or the plan fails, stops or waits -// on the operator. It is derived from the plan alone (buildPhaseOf), so a controller that restarts finds the same answer. The -// key is put when the first build is asked (sayBuilding), kept current at every save of the plan (keepBuildingKey) and by a -// look every link.PlansBuildingRefresh while this controller holds the lease (keepingPlansBuilding), and deleted at the phase's -// end; the bucket's age removes the key of a controller that stopped, so checks are held at most that long. -// -// The layout is documented in internal/link/holds.go. - -// buildPhaseOf says whether a plan is in its build phase: open, not waiting for the operator's word, with a build asked and a -// build of its tiers still to be built. A tier built and sent leaves the plan rolling while later tiers are still to be asked; -// it is in the phase still, which is what keeps a check from taking a place between two tiers. -func buildPhaseOf(p inventory.Plan) bool { - if !p.Open() || p.Waiting() || p.Release != nil { - return false - } - asked, unbuilt := false, false - for _, tier := range p.Tiers { - for _, m := range tier { - st := p.Modules[m] - if st != nil && st.State != "" { - asked = true - } - if st == nil || (st.State != "built" && st.State != planDeleted) { - unbuilt = true - } - } - } - return asked && unbuilt -} - -// buildingSince is when the plan's first build was asked, or fallback when no module says. -func buildingSince(p inventory.Plan, fallback time.Time) time.Time { - since := time.Time{} - for _, st := range p.Modules { - if st != nil && st.AskedAt != nil && (since.IsZero() || st.AskedAt.Before(since)) { - since = *st.AskedAt - } - } - if since.IsZero() { - return fallback - } - return since -} - -// buildingStore is the bucket of the plans building. -type buildingStore interface { - Put(ctx context.Context, plan string, value []byte) error - Delete(ctx context.Context, plan string) error - Keys(ctx context.Context) ([]string, error) -} - -// syncBuilding makes the bucket say what the plans say: a key for each plan in its build phase, put again, and none for any -// other plan, deleted. It returns the faults, each once. -func syncBuilding(ctx context.Context, store buildingStore, plans []inventory.Plan, now time.Time) []error { - var faults []error - live := map[string]bool{} - for _, p := range plans { - if !buildPhaseOf(p) { - continue - } - live[p.ID] = true - if err := putBuilding(ctx, store, p.ID, buildingSince(p, now), now); err != nil { - faults = append(faults, err) - } - } - keys, err := store.Keys(ctx) - if err != nil { - return append(faults, err) - } - for _, key := range keys { - if live[key] { - continue - } - if err := store.Delete(ctx, key); err != nil { - faults = append(faults, fmt.Errorf("the key of %s could not be taken away: %w", key, err)) - } - } - return faults -} - -func putBuilding(ctx context.Context, store buildingStore, plan string, since, now time.Time) error { - body, _ := json.Marshal(link.PlanBuilding{Plan: plan, Since: since.UTC(), Seen: now.UTC()}) - if err := store.Put(ctx, plan, body); err != nil { - return fmt.Errorf("the key of %s could not be put: %w", plan, err) - } - return nil -} - -// busBuilding is the store on the controller's bucket. -type busBuilding struct { - conn *nats.Conn - mu sync.Mutex - kv jetstream.KeyValue -} - -func (b *busBuilding) bucket(ctx context.Context) (jetstream.KeyValue, error) { - b.mu.Lock() - defer b.mu.Unlock() - if b.kv != nil { - return b.kv, nil - } - api, err := jetstream.New(b.conn) - if err != nil { - return nil, err - } - kv, err := api.KeyValue(ctx, broker.PlansBuildingBucket) - if err != nil { - return nil, fmt.Errorf("the plans building: %w", err) - } - b.kv = kv - return kv, nil -} - -func (b *busBuilding) Put(ctx context.Context, plan string, value []byte) error { - kv, err := b.bucket(ctx) - if err != nil { - return err - } - _, err = kv.Put(ctx, plan, value) - return err -} - -func (b *busBuilding) Delete(ctx context.Context, plan string) error { - kv, err := b.bucket(ctx) - if err != nil { - return err - } - if err := kv.Delete(ctx, plan); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { - return err - } - return nil -} - -func (b *busBuilding) Keys(ctx context.Context) ([]string, error) { - kv, err := b.bucket(ctx) - if err != nil { - return nil, err - } - keys, err := kv.Keys(ctx) - if errors.Is(err, jetstream.ErrNoKeysFound) { - return nil, nil - } - return keys, err -} - -// buildingKeys is the store of the serving controller; nil in a process that is not it, which says nothing. -var buildingKeys atomic.Pointer[buildingStore] - -func setBuildingStore(s buildingStore) { - if s == nil { - buildingKeys.Store(nil) - return - } - buildingKeys.Store(&s) -} - -// sayBuilding puts a plan's key: its first build is being asked (or another of its builds). A key that could not be put is -// said; the look that follows puts it again. -func sayBuilding(ctx context.Context, plan string, since time.Time) { - s := buildingKeys.Load() - if s == nil { - return - } - if err := putBuilding(ctx, *s, plan, since, time.Now()); err != nil { - fmt.Printf("plans: %v; checks may take a place of %s until the next look\n", err, plan) - } -} - -// keepBuildingKey is told each plan this controller keeps: its key put again while it builds and taken away once it does not. -func keepBuildingKey(ctx context.Context, p inventory.Plan) { - s := buildingKeys.Load() - if s == nil { - return - } - if buildPhaseOf(p) { - sayBuilding(ctx, p.ID, buildingSince(p, time.Now())) - return - } - if err := (*s).Delete(ctx, p.ID); err != nil { - fmt.Printf("plans: the key of %s could not be taken away; it ages out: %v\n", p.ID, err) - } -} - -// keepingPlansBuilding looks at the plans every link.PlansBuildingRefresh for as long as this controller serves and holds the -// lease, so a key is put again before its age and the key of a plan that left the phase unseen is taken away. -func keepingPlansBuilding(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn) { - store := &busBuilding{conn: conn} - setBuildingStore(store) - defer setBuildingStore(nil) - tick := time.NewTicker(broker.PlansBuildingRefresh) - defer tick.Stop() - failing := "" - for { - if epoch, err := theLease.epoch(ctx); err == nil && epoch != 0 { - why := "" - if plans, err := inv.OpenPlans(ctx); err != nil { - why = fmt.Sprintf("the plans cannot be read: %v", err) - } else if faults := syncBuilding(ctx, store, plans, time.Now()); len(faults) > 0 { - why = fmt.Sprint(faults) - } - if why != failing { - if why != "" { - fmt.Printf("plans: the keys of the plans building cannot be kept: %s\n", why) - } else { - fmt.Printf("plans: the keys of the plans building are kept again\n") - } - failing = why - } - } - select { - case <-ctx.Done(): - return - case <-tick.C: - } - } -} diff --git a/cmd/mesh-controller/plans_building_test.go b/cmd/mesh-controller/plans_building_test.go deleted file mode 100644 index 94d7d3bb..00000000 --- a/cmd/mesh-controller/plans_building_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "sort" - "sync" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// Issue 1687 (ADR 0483 clause 2): a delivery plan's key lives from the ask of its first build until every build of its last -// tier is built, or the plan fails, stops or waits on the operator. - -type fakeBuildingStore struct { - mu sync.Mutex - keys map[string]link.PlanBuilding - puts map[string]int -} - -func newFakeBuildingStore() *fakeBuildingStore { - return &fakeBuildingStore{keys: map[string]link.PlanBuilding{}, puts: map[string]int{}} -} - -func (f *fakeBuildingStore) Put(_ context.Context, plan string, value []byte) error { - f.mu.Lock() - defer f.mu.Unlock() - var v link.PlanBuilding - if err := json.Unmarshal(value, &v); err != nil { - return err - } - f.keys[plan] = v - f.puts[plan]++ - return nil -} - -func (f *fakeBuildingStore) Delete(_ context.Context, plan string) error { - f.mu.Lock() - defer f.mu.Unlock() - delete(f.keys, plan) - return nil -} - -func (f *fakeBuildingStore) Keys(context.Context) ([]string, error) { - f.mu.Lock() - defer f.mu.Unlock() - var out []string - for k := range f.keys { - out = append(out, k) - } - sort.Strings(out) - return out, nil -} - -func aPlan(id, state string, tiers [][]string, states map[string]string) inventory.Plan { - p := inventory.Plan{ID: id, State: state, Tiers: tiers, Modules: map[string]*inventory.PlanModule{}} - now := time.Now().Add(-time.Minute) - for m, st := range states { - pm := &inventory.PlanModule{State: st} - if st != "" { - pm.AskedAt = &now - } - p.Modules[m] = pm - } - return p -} - -func TestAPlanIsInItsBuildPhaseFromTheFirstAskToTheLastBuild(t *testing.T) { - tiers := [][]string{{"a", "b"}, {"c"}} - cases := []struct { - name string - plan inventory.Plan - want bool - }{ - {"no build asked yet", aPlan("p", inventory.PlanBuilding, tiers, nil), false}, - {"the first tier asked", aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "asked", "b": "asked"}), true}, - // Between two tiers: the first built and sent (rolling), the second not asked yet. - {"between two tiers", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built"}), true}, - {"the last tier asked", aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "built", "b": "built", "c": "asked"}), true}, - {"every build built", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built", "c": "built"}), false}, - // A module the plan deletes is done as far as its tiers go (release_plan.go), so it holds no key through the rollout. - {"a deleted module is done", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built", "c": planDeleted}), false}, - {"failed", aPlan("p", inventory.PlanFailed, tiers, map[string]string{"a": "failed", "b": "asked"}), false}, - {"superseded", aPlan("p", inventory.PlanSuperseded, tiers, map[string]string{"a": "asked"}), false}, - {"a batch whose window is open", aPlan("p", inventory.PlanAssembling, tiers, map[string]string{"a": "asked"}), false}, - } - for _, c := range cases { - if got := buildPhaseOf(c.plan); got != c.want { - t.Errorf("%s: in the build phase = %v, want %v", c.name, got, c.want) - } - } - waiting := aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "asked"}) - waiting.Delivery = &inventory.PlanDelivery{Awaits: "the operator's word"} - if buildPhaseOf(waiting) { - t.Error("a plan that waits on the operator is in its build phase") - } -} - -// The key's life: put, refreshed, deleted at the phase's end; a plan that left the phase unseen loses its key at the next look. -func TestAPlanKeyIsPutRefreshedAndDeletedAtThePhasesEnd(t *testing.T) { - ctx := context.Background() - store := newFakeBuildingStore() - tiers := [][]string{{"a"}, {"b"}} - building := aPlan("plan-1", inventory.PlanBuilding, tiers, map[string]string{"a": "asked"}) - - if faults := syncBuilding(ctx, store, []inventory.Plan{building}, time.Now()); len(faults) > 0 { - t.Fatal(faults) - } - if _, ok := store.keys["plan-1"]; !ok { - t.Fatal("no key while the first build is asked") - } - first := store.keys["plan-1"] - // Refreshed: put again, the phase's start kept. - time.Sleep(2 * time.Millisecond) - syncBuilding(ctx, store, []inventory.Plan{building}, time.Now()) - if store.puts["plan-1"] != 2 || !store.keys["plan-1"].Seen.After(first.Seen) || !store.keys["plan-1"].Since.Equal(first.Since) { - t.Errorf("the key was not refreshed with its start kept: %d puts, %+v then %+v", store.puts["plan-1"], first, store.keys["plan-1"]) - } - // Between the tiers: still there. - between := aPlan("plan-1", inventory.PlanRolling, tiers, map[string]string{"a": "built"}) - syncBuilding(ctx, store, []inventory.Plan{between}, time.Now()) - if _, ok := store.keys["plan-1"]; !ok { - t.Error("the key was gone between two tiers") - } - // A key of another plan that is no longer in the phase. - store.keys["plan-0"] = link.PlanBuilding{Plan: "plan-0"} - done := aPlan("plan-1", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built"}) - syncBuilding(ctx, store, []inventory.Plan{done}, time.Now()) - if len(store.keys) != 0 { - t.Errorf("keys remain after the last build was built: %v", store.keys) - } -} - -// keepBuildingKey, told of each save, puts the key while the plan builds and deletes it when the plan fails. -func TestASavedPlanPutsOrDeletesItsKey(t *testing.T) { - ctx := context.Background() - store := newFakeBuildingStore() - var s buildingStore = store - buildingKeys.Store(&s) - defer buildingKeys.Store(nil) - - p := aPlan("plan-7", inventory.PlanBuilding, [][]string{{"a"}}, map[string]string{"a": "asked"}) - keepBuildingKey(ctx, p) - if _, ok := store.keys["plan-7"]; !ok { - t.Fatal("the key was not put when the plan was saved building") - } - p.State = inventory.PlanFailed - keepBuildingKey(ctx, p) - if _, ok := store.keys["plan-7"]; ok { - t.Fatal("the key stayed after the plan failed") - } -} - -// The plan's key is there before its first build is asked, so a check cannot take a place between the ask and the plan's save. -func TestThePlansKeyIsPutBeforeItsFirstBuildIsAsked(t *testing.T) { - asksOfEveryModule(t) - store := newFakeBuildingStore() - var s buildingStore = store - buildingKeys.Store(&s) - defer buildingKeys.Store(nil) - p, byName := aTierOfThree() - keyThere := false - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { - _, keyThere = store.keys["plan-1"] - return "build-x", nil - } - t.Cleanup(func() { askABuild = was }) - askModule(t.Context(), p, "a", byName) - if !keyThere { - t.Fatal("the plan's key was not there when its first build was asked") - } -} diff --git a/cmd/mesh-controller/plans_follow.go b/cmd/mesh-controller/plans_follow.go index cae2f5ba..3edf71d9 100644 --- a/cmd/mesh-controller/plans_follow.go +++ b/cmd/mesh-controller/plans_follow.go @@ -149,11 +149,11 @@ func planWaitCommand(ctx context.Context, inv *inventory.Inventory, id, untilAsk } // Said as `plans ` says its first line, and as `status` says the plan. now, one := time.Now(), []inventory.Plan{p} - bounds, pause := readTierBounds(ctx, inv, now), buildSeatPause(ctx, inv, one) - line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string { + bounds := readTierBounds(ctx, inv, now) + line := planLineSaid(p, now, bounds.of(p.Repository), func(module string) []string { on, _ := inv.Running(ctx, module) return on }, waitsOf(ctx, inv, one)) + heldSaid(p, heldNow(ctx, inv)) - doc := planWaitedDoc(p, planStatuses(one, now, pause, bounds)[0], until, came, now.Sub(began), line) + doc := planWaitedDoc(p, planStatuses(one, now, bounds)[0], until, came, now.Sub(began), line) return json.NewEncoder(os.Stdout).Encode(doc) } diff --git a/cmd/mesh-controller/plans_follow_test.go b/cmd/mesh-controller/plans_follow_test.go index 0bd63bdc..33b19bc8 100644 --- a/cmd/mesh-controller/plans_follow_test.go +++ b/cmd/mesh-controller/plans_follow_test.go @@ -94,7 +94,7 @@ func TestAWaitIsRefusedWhatItCannotDo(t *testing.T) { // the plan and what ended the wait, and its output is the line a person reads — never JSON inside a string. func TestAWaitForAPlanAnswersAsData(t *testing.T) { p := followedPlan(7, inventory.PlanRolling, 1) - st := planStatuses([]inventory.Plan{p}, time.Now(), pauseView{}, tierBounds{})[0] + st := planStatuses([]inventory.Plan{p}, time.Now(), tierBounds{})[0] doc := planWaitedDoc(p, st, "tier", "tier", 3*time.Second, "rolling, tier 2 of 2") stdout, _ := json.Marshal(doc) a := answerOf([]string{"plans", "wait", "plan-1"}, stdout, "", true) diff --git a/cmd/mesh-controller/predefined.go b/cmd/mesh-controller/predefined.go index 87dbf140..946dadf6 100644 --- a/cmd/mesh-controller/predefined.go +++ b/cmd/mesh-controller/predefined.go @@ -2,13 +2,9 @@ package main import ( "context" - "fmt" - "path" - "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) // The list of predefined keys the mesh publishes (novox/hq ADR 0348 §7, design 57 package K2, issue 661). @@ -20,48 +16,6 @@ import ( // against the list kept (catalogue.UsePredefined). mesh-config publishes it on the bus from there (design 57 // package K3); the controller keeps no config value, only the keys. -// fromTheCatalogue is whether a build is of the catalogue's repository, whose list alone the mesh publishes: by -// its name, in the URL form or the seat form. -func fromTheCatalogue(result link.BuildResult) bool { - for _, repository := range []string{result.Repository, sourceRepository(result)} { - name := strings.TrimSuffix(path.Base(strings.TrimRight(repository, "/")), ".git") - if name == catalogue.CatalogueRepository { - return true - } - } - return false -} - -func sourceRepository(result link.BuildResult) string { - if result.Source == nil { - return "" - } - return result.Source.Repository -} - -// carriedList is the list a build of the catalogue carried, read strictly; nil for a build of another -// repository, or one that carried none. -func carriedList(result link.BuildResult) (catalogue.PredefinedList, error) { - if result.Predefined == nil || !fromTheCatalogue(result) { - return nil, nil - } - list, err := catalogue.ParsePredefined(result.Predefined.Keys) - if err != nil { - return nil, fmt.Errorf("%s carried a list of predefined keys that is refused: %w", result.ID, err) - } - return list, nil -} - -// judgedWith is the list a build's manifest is first judged against at registration: the catalogue's own at the -// commit built, which its build judged it against, where it carried one; the list kept otherwise. -func judgedWith(result link.BuildResult) (catalogue.PredefinedList, error) { - list, err := carriedList(result) - if err != nil || list != nil { - return list, err - } - return catalogue.PublishedPredefined(), nil -} - // loadPredefined makes the list kept in the store the one this controller judges manifests against. A store // keeping none leaves the list it has. func loadPredefined(ctx context.Context, inv *inventory.Inventory) error { @@ -72,22 +26,3 @@ func loadPredefined(ctx context.Context, inv *inventory.Inventory) error { catalogue.UsePredefined(kept.Keys) return nil } - -// publishPredefined keeps a trunk build's list when it is newer than the one kept, says so, and loads what the -// store keeps then — this build's, or a newer one another controller process kept. -func publishPredefined(ctx context.Context, inv *inventory.Inventory, result link.BuildResult, - list catalogue.PredefinedList) error { - kept, err := inv.KeepPredefined(ctx, inventory.PredefinedRecord{Keys: list, Repository: result.Repository, - Commit: result.Commit, Generation: result.Predefined.Generation, Build: result.ID}) - if err != nil { - return err - } - if kept { - fmt.Printf("%s: the list of predefined keys at %s (generation %d) is published: %s\n", result.ID, - short(result.Commit), result.Predefined.Generation, strings.Join(list.Keys(), ", ")) - } else { - fmt.Printf("%s: the list of predefined keys at %s (generation %d) is not newer than the one kept, which "+ - "stands\n", result.ID, short(result.Commit), result.Predefined.Generation) - } - return loadPredefined(ctx, inv) -} diff --git a/cmd/mesh-controller/predefined_head_test.go b/cmd/mesh-controller/predefined_head_test.go new file mode 100644 index 00000000..dc31b5fc --- /dev/null +++ b/cmd/mesh-controller/predefined_head_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// aForgeAnsweringTheCatalogue stands in for the forge's wire alone (askGitea): the head of the catalogue's trunk and its +// predefined.json. It says which repository path was asked. +func aForgeAnsweringTheCatalogue(t *testing.T) *[]string { + t.Helper() + var asked []string + was := askGitea + askGitea = func(_ context.Context, _ string, args map[string]any, into any) error { + p, _ := args["path"].(string) + asked = append(asked, p) + switch { + case strings.Contains(p, "/commits"): + return json.Unmarshal([]byte(`[{"sha":"abcdef0123456789","commit":{"committer":{"date":"2026-10-23T12:00:00Z"}}}]`), into) + case strings.Contains(p, "/contents/"): + body, _ := json.Marshal(map[string]string{"encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(`{}`))}) + return json.Unmarshal(body, into) + } + return errors.New("asked " + p) + } + t.Cleanup(func() { askGitea = was }) + return &asked +} + +func aFormsOnTheForge() *sourceForms { + return sourceFormsRead(context.Background(), time.Now, func(context.Context) (catalogue.World, error) { + return forgeHolding(float64(20000)), nil + }) +} + +// Issue 1902: the catalogue's repository is found among the modules' sources in the URL form (what every live row has) and +// in the seat form, `.git` dropped; the real lookup runs, only the forge's wire is a stand-in. +func TestTheCataloguesRepositoryIsFoundInTheURLFormAndTheSeatForm(t *testing.T) { + for name, source := range map[string]inventory.Source{ + "the URL form": {Repository: "http://anchor.internal:20000/novox/mesh-catalog.git", Path: "modules/sudo"}, + "the seat form": {Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/sudo"}, + } { + asked := aForgeAnsweringTheCatalogue(t) + entries := []inventory.Entry{ + {Manifest: catalogue.Manifest{Module: "other"}, Source: inventory.Source{Repository: "http://anchor.internal:20000/novox/mesh-delivery.git"}}, + {Manifest: catalogue.Manifest{Module: "sudo"}, Source: source}, + } + head, err := readPredefinedHead(t.Context(), entries, aFormsOnTheForge()) + if err != nil || head.Repository != "novox/mesh-catalog" || head.Commit != "abcdef0123456789" { + t.Fatalf("%s: the head is %+v (%v)", name, head, err) + } + if len(*asked) != 2 || !strings.HasPrefix((*asked)[0], "/repos/novox/mesh-catalog/commits") { + t.Errorf("%s: the forge was asked %v", name, *asked) + } + } +} + +// And a mesh where nothing is built from the catalogue's repository says so by name, as an error that is not a wait. +func TestNoModuleBuiltFromTheCataloguesRepositoryIsNamedNotWaitedFor(t *testing.T) { + aForgeAnsweringTheCatalogue(t) + entries := []inventory.Entry{{Manifest: catalogue.Manifest{Module: "other"}, + Source: inventory.Source{Repository: "http://anchor.internal:20000/novox/mesh-delivery.git"}}} + _, err := readPredefinedHead(t.Context(), entries, aFormsOnTheForge()) + if !errors.Is(err, errNoCatalogueRepository) || errors.Is(err, errKeyWaits) { + t.Fatalf("a mesh with no catalogue module: %v", err) + } +} diff --git a/cmd/mesh-controller/predefined_integration_test.go b/cmd/mesh-controller/predefined_integration_test.go index f999e3dc..9a7c1dd9 100644 --- a/cmd/mesh-controller/predefined_integration_test.go +++ b/cmd/mesh-controller/predefined_integration_test.go @@ -10,28 +10,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" ) -func TestTheMergeGateJudgesEachManifestAgainstTheListOfTheTree(t *testing.T) { - withoutAPublishedList(t) - f, manifests := templatedMesh(t) - - v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier", - notifierFallingBackTo("font-size", "number"))), theFirstKeys), "modules/notifier/module.json") - if v.Verdict == "fail" || v.Modules["notifier"] != "changed" { - t.Fatalf("a module naming font-size on the tree's list failed the gate (%v):\n%s", v.Modules, v.Report()) - } - for _, c := range []struct{ manifest, list, says string }{ - {notifierFallingBackTo("font-scale", "number"), theFirstKeys, `"font-scale", which is not on the list`}, - {notifierFallingBackTo("font-size", "string"), theFirstKeys, "which is a number"}, - {inlineNotifier, `{"font-size": {"type": "number", "description": "points", "unit": "pt"}}`, `unknown field "unit"`}, - } { - v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier", c.manifest)), c.list), - "modules/notifier/module.json", catalogue.PredefinedFile) - if v.Verdict != "fail" || !strings.Contains(v.Report(), c.says) { - t.Errorf("wanted the gate to fail saying %q:\n%s", c.says, v.Report()) - } - } -} - func TestRegistrationPublishesTheNewestTrunkListAndJudgesEveryModuleAgainstIt(t *testing.T) { withoutAPublishedList(t) open := aMesh(t) diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 41d88d4f..1e8efc6f 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -20,6 +20,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose" "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" @@ -183,9 +184,6 @@ func serve(ctx context.Context) (err error) { // somebody deleted, a mesh raised from a restored backup, or a bus whose data directory was // replaced all have records and no objects — and a node whose consumer is missing hears nothing // while everything else about it looks correct. - // And build results nobody was waiting for. A build triggered any other way than `build` - // would otherwise be reported into the void, which is the same as not reporting it. - server.Records(builds{inv, open}) // Open plans move on events (novox/hq ADR 0406 §5, issue 974): a tier is asked on the `built` and the send of the // tier before it, a walk ends on its machines' reports, a silent machine is a key that expired, and a plan left // by a replaced controller resumes from the first look, which this asks for. No timer looks every so often. @@ -286,8 +284,6 @@ func serve(ctx context.Context) (err error) { go mirroringRecords(ctx, open, inv, bus.Conn, bus) // And the bucket of the files that travel by digest, judged against what each machine's latest declaration names (ADR 0486 §3). go sweepingDeclarationFiles(ctx, inv, bus.Conn) - // And the key of each plan in its build phase, which holds the build seat's checks while a delivery builds (ADR 0483 clause 2). - go keepingPlansBuilding(ctx, inv, bus.Conn) // And the modules the catalogue does not hold yet, registered from the builder's first done key of each, woken by // the builder's and the indexer's writes (novox/hq ADR 0501 §6, issue 1851): nothing asks a first build. watchNewModules(ctx, open) @@ -1154,6 +1150,15 @@ func (b overTheBus) declare(ctx context.Context, s readyNode, body []byte) (stri if err != nil { return "", err } + // The list sent issued the credential of every module it carried, at the fingerprint each is registered at: + // the grants the assigner's sixth rule reads (novox/hq ADR 0528 clause 2, issue 1394). + if bus, ok := b.server.Bus().(link.OverNATS); ok { + if js, err := jetstream.New(bus.Conn); err == nil { + writing, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + grantsIssued(writing, b.open.inventory, js, b.indent) + cancel() + } + } } fmt.Printf("%ssent %s %d resource(s)\n", b.indent, s.node, len(s.declared.Resources)) return digest, nil @@ -1624,11 +1629,6 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string) if err != nil { return err } - // And each work queue's cancelled set (novox/hq ADR 0219), so a holder taking an ask can ask - // whether it was cancelled the moment it took it. - if err := broker.RaiseCancelledSets(js, inventory.MeshSeats()); err != nil { - return err - } // And the controller's own buckets (novox/hq to-be 45 §1): the calls it serves and the acts done // by hand, kept where a restart of this process does not take them. if err := js.EnsureControllerBuckets(); err != nil { diff --git a/cmd/mesh-controller/queue.go b/cmd/mesh-controller/queue.go deleted file mode 100644 index a11ade45..00000000 --- a/cmd/mesh-controller/queue.go +++ /dev/null @@ -1,673 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "os" - "regexp" - "sort" - "strings" - "time" - - "github.com/nats-io/nats.go" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// The build queue, controlled by hand (novox/hq ADR 0219). -// -// Seen whole — what waits, what runs where and for how long, what was handed out as often as it -// may be and never settled — and changed through the controller: an ask cancelled, the queue -// cleared, a module rebuilt, a build replayed. What one machine is running is that machine's -// holder's to end or pause, and the controller asks it (`kill`, `pause`, `resume`). -// -// **Everything that drops an ask leaves a failed outcome for it**, taken in exactly as a build that -// failed is (takeIn, then the plan): a plan waiting on an ask a person removed fails, saying so, -// rather than waiting for ever on an answer nobody will give. - -// holderAsks is how long a holder's verb is waited for; killAnswer how long its kill is — longer -// than the holder's worst case (its two passes removing containers and its wait between, 50s). -const ( - holderAsks = 15 * time.Second - killAnswer = 75 * time.Second -) - -// dialTheBus is the controller's connection, for a command that reads or changes the queue: the serving -// controller's own, lent, when this process is it (novox/hq issue 327). -func dialTheBus() (*broker.JetStream, error) { - return aBus() -} - -// queueCommand prints every ask in the build seat's work queue. -func queueCommand(ctx context.Context, args []string) error { - return listQueue(ctx, args, os.Stdout) -} - -// listQueue is `queue`: the build queue, as a person reads it or as JSON. -func listQueue(ctx context.Context, args []string, w io.Writer) error { - set := flag.NewFlagSet("queue", flag.ContinueOnError) - usageTo(set, w) - asJSON := set.Bool("json", false, "the queue as JSON") - if _, err := parseAround(set, args); err != nil { - return err - } - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - seat := buildSeatHeld(ctx) - q, err := link.ReadQueue(ctx, js, seat) - if err != nil { - return err - } - now := time.Now() - if *asJSON { - body, err := json.MarshalIndent(queueAnswerOf(q, now), "", " ") - if err != nil { - return err - } - fmt.Fprintln(w, string(body)) - return nil - } - fmt.Fprint(w, queueText(q, now)) - return nil -} - -// queueAnswer is the queue as data (novox/hq issue 455): what a caller reads to say how far back an ask -// waits — "waiting in the build queue behind 2" — which the table alone could not tell it. Text is the -// table, written from the same read: the verb answers it as its output and the rest as its answer, so the -// two never disagree. -type queueAnswer struct { - Seat string `json:"seat"` - Waiting int `json:"waiting"` - InFlight int `json:"in-flight"` - Dead int `json:"dead"` - MaxDeliver int `json:"max-deliver"` - Asks []queuedAnswer `json:"asks"` - // HeldFor are the delivery plans building, while which the waiting checks are held (ADR 0483 clause 5); empty when none. - HeldFor []string `json:"held-for,omitempty"` - Text string `json:"text"` -} - -// queuedAnswer is one ask as data. Never what the request carries beyond its source — not `held`, every -// artifact the mesh has built, nor a check's checkouts beside it. -type queuedAnswer struct { - ID string `json:"id"` - State string `json:"state"` - // Kind is "delivery" for a build a walk waits for, "check" for a pull request's check and any ask - // whose kind cannot be told (link.KindOf, novox/hq issue 558). - Kind string `json:"kind"` - Repository string `json:"repository"` - Path string `json:"path,omitempty"` - Ref string `json:"ref,omitempty"` - // Commit is the ref when it names a commit; empty when the ask follows a branch or the default. - Commit string `json:"commit,omitempty"` - // Machine is the one building an ask in flight; WasOn the one that started it and handed it back. - Machine string `json:"machine,omitempty"` - WasOn string `json:"was-on,omitempty"` - AskedAt time.Time `json:"asked-at,omitzero"` - // Since is when the ask came to its state: its start on the machine for one in flight that a - // machine said it started, otherwise when it was asked. - Since time.Time `json:"since,omitzero"` - // Position is its place among the waiting, 1 being handed out next; 0 for an ask not waiting. - Position int `json:"position,omitempty"` - Seq uint64 `json:"seq"` - // Delivery is the how-manyth time an ask in flight was handed out; more than 1 is a restart - // (novox/hq issue 594). - Delivery int `json:"delivery,omitempty"` - // LetThrough is a check that waited past 30 minutes and is let through while a delivery builds: the next one, or the one - // that runs (ADR 0483 clause 5). - LetThrough bool `json:"let-through,omitempty"` -} - -// aCommitRef is a ref that names a commit by its full hash (SHA-1 or SHA-256). -var aCommitRef = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) - -// queueAnswerOf is the queue as data, in the table's order: in flight, the waiting from the next one to -// be handed out, then the dead. A pure function of one read (novox/hq issue 455). -func queueAnswerOf(q link.Queue, now time.Time) queueAnswer { - waiting, running, dead := q.Of(link.AskWaiting), q.Of(link.AskInFlight), q.Of(link.AskDead) - out := queueAnswer{Seat: q.Seat, Waiting: len(waiting), InFlight: len(running), Dead: len(dead), - MaxDeliver: q.MaxDeliver, Asks: []queuedAnswer{}, HeldFor: q.Hold.Plans, Text: queueText(q, now)} - letNext, letRunning := q.LetThroughOf(now) - one := func(a link.QueuedAsk, position int) queuedAnswer { - kind := link.KindOf(a.Request) - since := a.AskedAt - if a.State == link.AskInFlight && a.On != "" && !a.Started.IsZero() { - since = a.Started - } - commit := "" - if aCommitRef.MatchString(a.Ref) { - commit = a.Ref - } - return queuedAnswer{ID: a.ID, State: a.State, Kind: kind, Repository: link.WithoutPassword(a.Repository), Path: a.Path, - Ref: a.Ref, Commit: commit, Machine: a.On, WasOn: a.Was, AskedAt: a.AskedAt, Since: since, - Position: position, Seq: a.Seq, Delivery: a.Delivery, LetThrough: a.ID != "" && (a.ID == letNext || a.ID == letRunning)} - } - for _, a := range running { - out.Asks = append(out.Asks, one(a, 0)) - } - for i, a := range waiting { - out.Asks = append(out.Asks, one(a, i+1)) - } - for _, a := range dead { - out.Asks = append(out.Asks, one(a, 0)) - } - return out -} - -// queueText is the queue as a person reads it: a summary line, then each kind in queue order. -// Never what an ask carries as `held` — that is every artifact the mesh has built. -func queueText(q link.Queue, now time.Time) string { - var b strings.Builder - waiting, running, dead := q.Of(link.AskWaiting), q.Of(link.AskInFlight), q.Of(link.AskDead) - fmt.Fprintf(&b, "%s: %d waiting, %d in flight, %d dead\n", q.Seat, len(waiting), len(running), len(dead)) - ago := func(t time.Time) string { - if t.IsZero() { - return "asked at an unknown time" - } - return "asked " + now.Sub(t).Round(time.Second).String() + " ago" - } - what := func(a link.QueuedAsk) string { - s := link.WithoutPassword(a.Repository) - if a.Path != "" { - s += " at " + a.Path - } - if a.Ref != "" { - s += " on " + a.Ref - } - return s - } - letNext, letRunning := q.LetThroughOf(now) - const passed = "waited past 30 minutes, let through" - if q.HoldFault != "" { - fmt.Fprintf(&b, "why checks wait cannot be read, so none is said held: %s\n", q.HoldFault) - } - if len(running) > 0 { - fmt.Fprintln(&b, "\nin flight:") - for _, a := range running { - where := "taken, not yet said where" - switch { - case a.On != "": - where = fmt.Sprintf("on %s for %s", a.On, now.Sub(a.Started).Round(time.Second)) - // Started again after an earlier taker left it (novox/hq issue 594). - if a.Delivery > 1 { - where += fmt.Sprintf(", handed out %d times", a.Delivery) - } - case a.Was != "": - where = fmt.Sprintf("handed back by %s, to be handed out again", a.Was) - } - note := "" - if a.ID != "" && a.ID == letRunning { - note = " — " + passed - } - fmt.Fprintf(&b, " %-26s %-8s %s — %s, %s (seq %d)%s\n", a.ID, link.KindOf(a.Request), what(a), where, - ago(a.AskedAt), a.Seq, note) - } - } - if len(waiting) > 0 { - fmt.Fprintln(&b, "\nwaiting, in the order they are handed out:") - // **Why the checks wait** (ADR 0483 clause 5): above them, the plans they are held for. - if len(q.Hold.Plans) > 0 && hasCheck(waiting) { - fmt.Fprintf(&b, " checks are held while delivery %s builds; the oldest check that waited 30 minutes goes first, one at a time\n", - strings.Join(q.Hold.Plans, ", ")) - } - for _, a := range waiting { - note := "" - if a.ID != "" && a.ID == letNext { - note = " — " + passed + " next" - } - fmt.Fprintf(&b, " %-26s %-8s %s — %s (seq %d)%s\n", a.ID, link.KindOf(a.Request), what(a), ago(a.AskedAt), a.Seq, note) - } - } - if len(dead) > 0 { - fmt.Fprintf(&b, "\ndead — handed out as often as the worker allows (%d) and never settled, still held:\n", q.MaxDeliver) - for _, a := range dead { - fmt.Fprintf(&b, " %-26s %-8s %s — %s (seq %d)\n", a.ID, link.KindOf(a.Request), what(a), ago(a.AskedAt), a.Seq) - } - } - switch { - case len(q.Asks) == 0: - fmt.Fprintln(&b, "nothing is asked of it") - default: - fmt.Fprintln(&b, "\n`cancel ` drops a waiting or dead ask, `clear` every waiting one, `kill ` ends one in flight") - } - return b.String() -} - -// hasCheck says a check is among the asks. -func hasCheck(asks []link.QueuedAsk) bool { - for _, a := range asks { - if a.Request.Check != nil { - return true - } - } - return false -} - -// cancelCommand drops one waiting or dead ask. -func cancelCommand(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("cancel ") - } - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - seat := buildSeatHeld(ctx) - q, err := link.ReadQueue(ctx, js, seat) - if err != nil { - return err - } - ask, found := q.Find(args[0]) - if !found { - return fmt.Errorf("%s is not in the %s queue: it was built, cancelled, or never asked — `builds` says "+ - "what came of it", args[0], seat) - } - said, err := cancelAsk(ctx, js, open, seat, ask) - if err != nil { - return err - } - fmt.Println(said) - return nil -} - -// cancelAsk drops one ask from the queue and records it failed, cancelled by hand. -// -// **In this order, and each step for a reason** (novox/hq ADR 0219): -// 1. an ask a machine says it is building is refused: deleting its message ends nothing a machine -// is running — that is `kill`, on the machine running it; -// 2. its id goes into the seat's cancelled set, so a holder that fetches it from now on ends it; -// 3. for a waiting ask, the worker is read again: one handed out since the queue was read was -// taken in the moment of cancelling, and the cancel is withdrawn and refused — the holder either -// read the mark first and ends it as cancelled, or is building it; -// 4. for an ask the worker counts handed out that no machine is building — taken and not yet said, -// handed back after a restart, or past its deliveries while nobody pulls — the holder's own look -// at the set is waited out, and a start heard since withdraws and refuses the cancel: a holder -// that took it before the mark is building it, and only `kill` ends that; -// 5. the message is deleted by its sequence; -// 6. the failed outcome is taken in as any failed build's is, and the plan that asked fails. -func cancelAsk(ctx context.Context, js *broker.JetStream, open *stores, seat string, ask link.QueuedAsk) (string, error) { - if ask.State == link.AskInFlight && ask.On != "" { - return "", fmt.Errorf("%s is in flight on %s: cancelling drops an ask nobody is building. `kill %s` "+ - "ends the build where it runs", ask.ID, ask.On, ask.ID) - } - if err := link.MarkCancelled(ctx, js, seat, ask.ID); err != nil { - return "", err - } - withdraw := func() { - if err := link.UnmarkCancelled(ctx, js, seat, ask.ID); err != nil { - fmt.Fprintf(os.Stderr, "could not withdraw the cancel of %s: %v — a holder taking it ends it as cancelled\n", ask.ID, err) - } - } - // The worker of the queue it waits in: the seat's own, or one of the two check queues' (novox/hq ADR 0406 §3). - worker := link.WorkerOf(seat, ask) - switch ask.State { - case link.AskWaiting: - if taken, err := takenSince(js, worker, ask.Seq); err != nil { - withdraw() - return "", err - } else if taken { - withdraw() - return "", fmt.Errorf("%s was taken by a holder as it was cancelled, so the cancel is withdrawn. If the "+ - "holder read it first it ends the ask as %s and its outcome says so; otherwise it is building — "+ - "`queue` says which, and `kill %s` ends it", ask.ID, link.CancelledByHand, ask.ID) - } - case link.AskInFlight: - if started, err := startedSince(ctx, js, seat, ask); err != nil { - withdraw() - return "", err - } else if started != "" { - withdraw() - return "", fmt.Errorf("%s has started on %s since it was read, so the cancel is withdrawn: `kill %s` "+ - "ends it there", ask.ID, started, ask.ID) - } - } - removed, err := link.DeleteAsk(ctx, js, seat, ask) - if err != nil { - 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) - } - return cancelSaid(ask, seat, removed), nil -} - -// cancelSaid is what a cancel says: deleted from the queue only when it was. -func cancelSaid(ask link.QueuedAsk, seat string, removed uint64) string { - where := fmt.Sprintf("deleted from the %s queue", seat) - if removed == 0 { - where = fmt.Sprintf("no longer in the %s queue to delete, and marked cancelled, so a holder taking it "+ - "ends it", seat) - } - return fmt.Sprintf("cancelled %s (%s, %s): %s and recorded failed, %s — a plan "+ - "that asked for it fails with that", ask.ID, ask.Repository, ask.State, where, link.CancelledByHand) -} - -// holderLooks is how long a cancel waits for a holder that took the ask before the mark to say it -// started: longer than a holder's look at the cancelled set (3s) and its start that follows. -var holderLooks = 5 * time.Second - -// startedSince waits out a holder's look at the cancelled set and says the machine that started the -// ask since it was read, or nothing. A start it ended as cancelled comes with its outcome and is not -// a start of a build. -func startedSince(ctx context.Context, js *broker.JetStream, seat string, ask link.QueuedAsk) (string, error) { - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(holderLooks): - } - since := time.Now().Add(-7 * 24 * time.Hour) - if !ask.AskedAt.IsZero() { - since = ask.AskedAt.Add(-time.Minute) - } - heard, err := link.ReadBuildEvents(ctx, js, seat, since) - if err != nil { - return "", err - } - s, ok := heard.Started[ask.ID] - if !ok || heard.Outcomes[ask.ID] { - return "", nil - } - at, _ := time.Parse(time.RFC3339Nano, s.At) - if ask.Started.IsZero() || at.After(ask.Started) { - return s.On, nil - } - return "", nil -} - -// takenSince is whether the worker has handed out the ask at this sequence. -func takenSince(js *broker.JetStream, worker broker.Consumer, seq uint64) (bool, error) { - info, err := js.Context().ConsumerInfo(worker.Stream, worker.Name) - if errors.Is(err, nats.ErrConsumerNotFound) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("cannot read the worker of the queue again: %w", err) - } - return info.Delivered.Stream >= seq, nil -} - -// recordCancelled takes the failed outcome in the way the daemon takes in any build's: recorded, -// then the plan that asked for it — by the id it asked with, or by repository and path. -func recordCancelled(ctx context.Context, open *stores, ask link.QueuedAsk) { - r := ask.Request - result := link.BuildResult{ID: ask.ID, Repository: ask.Repository, Path: ask.Path, Ref: ask.Ref, - Source: r.Source, DryRun: r.DryRun, Failed: link.CancelledByHand} - _ = builds{open.inventory, open}.Built(ctx, result) -} - -// clearCommand cancels every waiting ask, and with --dead every dead one too. -func clearCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("clear", flag.ContinueOnError) - dead := set.Bool("dead", false, "the dead asks too") - if _, err := parseAround(set, args); err != nil { - return err - } - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - seat := buildSeatHeld(ctx) - said, err := clearQueue(ctx, js, open, seat, *dead) - fmt.Print(said) - return err -} - -// clearQueue cancels what clear names, each as `cancel` would, and never anything in flight. -func clearQueue(ctx context.Context, js *broker.JetStream, open *stores, seat string, dead bool) (string, error) { - q, err := link.ReadQueue(ctx, js, seat) - if err != nil { - return "", err - } - var b strings.Builder - cancelled, refused := 0, 0 - for _, a := range q.Asks { - if a.State == link.AskInFlight || (a.State == link.AskDead && !dead) { - continue - } - said, err := cancelAsk(ctx, js, open, seat, a) - if err != nil { - refused++ - fmt.Fprintf(&b, " %s: %v\n", a.ID, err) - continue - } - cancelled++ - fmt.Fprintf(&b, " %s\n", said) - } - what := "waiting" - if dead { - what = "waiting and dead" - } - fmt.Fprintf(&b, "%d %s ask(s) cancelled", cancelled, what) - if refused > 0 { - fmt.Fprintf(&b, ", %d could not be", refused) - } - fmt.Fprintln(&b) - if n := len(q.Of(link.AskInFlight)); n > 0 { - fmt.Fprintf(&b, "%d in flight, left running: `kill ` ends one where it runs\n", n) - } - if n := len(q.Of(link.AskDead)); n > 0 && !dead { - fmt.Fprintf(&b, "%d dead, left: `clear --dead` cancels them too\n", n) - } - if refused > 0 { - return b.String(), fmt.Errorf("%d ask(s) could not be cancelled", refused) - } - return b.String(), nil -} - -// sourceOfBuild is where a recorded build's repository is asked from: the catalogued module's own -// source when the build is of one — on its seat, so the outcome registers it as the mesh records it -// (ADR 0111) — else the repository as it was cloned. -func sourceOfBuild(b inventory.Build, entries []inventory.Entry) (buildSource, string) { - for _, e := range entries { - if (b.Module != "" && e.Manifest.Module == b.Module) || - (b.Module == "" && repositoryMatches(e.Source.Repository, b.Repository) && e.Source.Path == b.Path) { - return buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}, e.Manifest.Module - } - } - return buildSource{Repository: b.Repository}, b.Module -} - -// replayCommand asks a recorded build's repository and path again at the commit it built. -func replayCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("replay", flag.ContinueOnError) - register := set.Bool("register", false, "removed (novox/hq ADR 0501 §5): refused by name") - positionals, err := parseAround(set, args) - if err != nil { - return err - } - // **Registering a replay goes with the asks** (novox/hq ADR 0429 §18, ADR 0501 §5): refused by name. - if *register { - return removedByName("replay --register") - } - if len(positionals) != 1 { - return errors.New("replay ") - } - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - inv := open.inventory - b, found, err := inv.BuildByID(ctx, positionals[0]) - if err != nil { - return err - } - if !found { - return fmt.Errorf("no build %s is recorded", positionals[0]) - } - entries, err := inv.Catalogued(ctx) - if err != nil { - return err - } - source, module := sourceOfBuild(b, entries) - if b.Commit == "" { - return fmt.Errorf("%s recorded no commit — it failed before it knew what it was building, so there is "+ - "nothing to replay", b.ID) - } - id, err := buildOneAsked(ctx, source, b.Path, b.Commit, 0, true, "") - if err != nil { - return err - } - fmt.Println(replaySaid(b, module, id, false)) - return nil -} - -// replaySaid is what a replay prints once asked: what it builds, and what becomes of the outcome. -func replaySaid(b inventory.Build, module, id string, register bool) string { - what := b.Repository - if module != "" { - what = module - } - said := fmt.Sprintf("replaying %s (%s) at %s as %s", b.ID, what, short(b.Commit), id) - if !register { - return said + "\n a dry run: the outcome is looked at and not taken in — nothing is recorded or " + - "registered, and nothing is sent. `builds --log " + id + "` follows it; `--register` registers it" - } - return said + "\n registered when it is built, as the module's current version — newer than every build " + - "asked before now — and rolled out as its policy says. `builds --log " + id + "` follows it" -} - -// killCommand finds the machine running a build and asks its holder to end it. -func killCommand(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("kill ") - } - id := args[0] - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - seat := buildSeatHeld(ctx) - since := time.Now().Add(-7 * 24 * time.Hour) - if at, ok := link.BuildAskedAt(id); ok { - since = at.Add(-time.Minute) - } - heard, err := link.ReadBuildEvents(ctx, js, seat, since) - if err != nil { - return err - } - started, ok := heard.Started[id] - if !ok { - return fmt.Errorf("no machine has said it started %s: if it waits in the queue, `cancel %s` drops it", id, id) - } - if heard.Outcomes[id] { - return fmt.Errorf("%s has already ended on %s — `builds` says how", id, started.On) - } - answer, err := link.AskSeatTool(ctx, js.Conn(), seat, "kill", started.On, map[string]string{"id": id}, killAnswer) - if err != nil { - return err - } - return printHolderAnswer(started.On, answer) -} - -// pauseCommand asks one machine's holder, or every holder's, to pause or resume. -func pauseCommand(ctx context.Context, verb string, args []string) error { - if len(args) > 1 { - return fmt.Errorf("%s [node]", verb) - } - js, err := dialTheBus() - if err != nil { - return err - } - defer js.Close() - seat := buildSeatHeld(ctx) - nodes := args - if len(nodes) == 0 { - if nodes, err = buildSeatHolders(ctx, seat); err != nil { - return err - } - if len(nodes) == 0 { - return fmt.Errorf("nothing holds %s, so there is nothing to %s", seat, verb) - } - } - failed := 0 - for _, node := range nodes { - answer, err := link.AskSeatTool(ctx, js.Conn(), seat, verb, node, map[string]string{}, holderAsks) - if err != nil { - fmt.Printf("%s: %v\n", node, err) - failed++ - continue - } - if err := printHolderAnswer(node, answer); err != nil { - failed++ - } - } - if failed > 0 { - return fmt.Errorf("%d of %d machine(s) did not %s", failed, len(nodes), verb) - } - return nil -} - -// printHolderAnswer prints what a holder said, or its refusal as the command's failure. -func printHolderAnswer(node string, answer link.Answer) error { - if answer.Error != "" { - fmt.Printf("%s: %s\n", node, answer.Error) - return errors.New(answer.Error) - } - var said struct { - Said string `json:"said"` - } - if json.Unmarshal(answer.Result, &said) == nil && said.Said != "" { - fmt.Printf("%s: %s\n", node, said.Said) - return nil - } - fmt.Printf("%s: %s\n", node, string(answer.Result)) - return nil -} - -// buildSeatHolders is every machine an assigned module holding the build seat runs on. -func buildSeatHolders(ctx context.Context, seat string) ([]string, error) { - open, err := openStores(ctx) - if err != nil { - return nil, err - } - defer open.Close() - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - return nil, err - } - return holdersAmong(entries, seat), nil -} - -// holdersAmong is the machines of every catalogued module claiming the seat, sorted, once each. -func holdersAmong(entries []inventory.Entry, seat string) []string { - seen := map[string]bool{} - var out []string - for _, e := range entries { - if !e.Manifest.ClaimsSeat(seat) { - continue - } - for _, n := range e.On { - if !seen[n] { - seen[n] = true - out = append(out, n) - } - } - } - sort.Strings(out) - return out -} diff --git a/cmd/mesh-controller/queue_answer_test.go b/cmd/mesh-controller/queue_answer_test.go deleted file mode 100644 index 6f8cf6c9..00000000 --- a/cmd/mesh-controller/queue_answer_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package main - -import ( - "encoding/json" - "reflect" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/link" -) - -// The queue answers as data as well as text (novox/hq issue 455): each ask's repository, commit, kind, -// machine, since and position among the waiting, read from the same queue the table is written from. - -const aCommit = "0123456789abcdef0123456789abcdef01234567" - -func aQueueOfEachKind(now time.Time) link.Queue { - asked := now.Add(-5 * time.Minute) - started := now.Add(-2 * time.Minute) - return link.Queue{Seat: link.TheBuildMachine, Delivered: 2, AckPending: 1, MaxDeliver: 5, Asks: []link.QueuedAsk{ - {Seq: 1, ID: "build-dead", Repository: "ssh://forge/novox/x", AskedAt: asked, State: link.AskDead}, - {Seq: 2, ID: "build-running", Repository: "ssh://forge/novox/mesh-host", Ref: aCommit, AskedAt: asked, - State: link.AskInFlight, On: "ace", Started: started, - Request: link.BuildRequest{ID: "build-running", Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-host"}}}, - {Seq: 3, ID: "build-first", Repository: "ssh://forge/novox/mesh-catalog", Ref: "main", AskedAt: asked, - State: link.AskWaiting, Request: link.BuildRequest{ID: "build-first", Repository: "ssh://forge/novox/mesh-catalog"}}, - {Seq: 4, ID: "build-second", Repository: "ssh://forge/novox/mesh-catalog", Path: "modules/gitea", Ref: aCommit, - AskedAt: now.Add(-time.Minute), State: link.AskWaiting, - Request: link.BuildRequest{ID: "build-second", Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog"}}}, - }} -} - -func TestTheQueueVerbAnswersEachAskAsData(t *testing.T) { - now := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) - q := aQueueOfEachKind(now) - got := queueAnswerOf(q, now) - if got.Waiting != 2 || got.InFlight != 1 || got.Dead != 1 || got.Seat != link.TheBuildMachine { - t.Fatalf("counts: %+v", got) - } - if got.Text != queueText(q, now) { - t.Fatalf("the data's text is not the table read from the same queue:\n%s", got.Text) - } - byID := map[string]queuedAnswer{} - for _, a := range got.Asks { - byID[a.ID] = a - } - running := byID["build-running"] - if running.Kind != "check" || running.Machine != "ace" || running.Commit != aCommit || running.Position != 0 || - !running.Since.Equal(now.Add(-2*time.Minute)) || running.State != link.AskInFlight { - t.Errorf("in flight: %+v", running) - } - first, second := byID["build-first"], byID["build-second"] - if first.Position != 1 || first.Kind != "delivery" || first.Commit != "" || first.Ref != "main" || first.Machine != "" || - !first.Since.Equal(now.Add(-5*time.Minute)) { - t.Errorf("first waiting: %+v", first) - } - if second.Position != 2 || second.Kind != "check" || second.Commit != aCommit || second.Path != "modules/gitea" || - second.Repository != "ssh://forge/novox/mesh-catalog" { - t.Errorf("second waiting: %+v", second) - } - if dead := byID["build-dead"]; dead.Position != 0 || dead.State != link.AskDead { - t.Errorf("dead: %+v", dead) - } - // And the table says each ask's kind too (novox/hq issue 558). - for _, line := range []string{"build-running check ", "build-first delivery ", - "build-second check ", "build-dead check "} { - if !strings.Contains(got.Text, line) { - t.Errorf("the table does not say %q:\n%s", line, got.Text) - } - } - // In the table's order: in flight, then the waiting by position, then the dead. - var order []string - for _, a := range got.Asks { - order = append(order, a.ID) - } - if want := []string{"build-running", "build-first", "build-second", "build-dead"}; !reflect.DeepEqual(order, want) { - t.Errorf("order %v, want %v", order, want) - } -} - -// The verb asks its command for JSON, and its answer carries the data while its output stays the table a -// person reads, so a text caller reads what it always did. -func TestTheQueueVerbKeepsTheTableAndCarriesTheData(t *testing.T) { - argv, err := argvFor("queue", nil) - if err != nil || !reflect.DeepEqual(argv, []string{"queue", "--json"}) { - t.Fatalf("queue's command line: %v %v", argv, err) - } - now := time.Now() - q := aQueueOfEachKind(now) - body, err := json.Marshal(queueAnswerOf(q, now)) - if err != nil { - t.Fatal(err) - } - answer := answerOf(argv, body, "", true) - if answer.Output != queueText(q, now) { - t.Fatalf("the output is not the table:\n%s", answer.Output) - } - data, ok := answer.Answer.(map[string]any) - if !ok { - t.Fatalf("no data: %#v", answer.Answer) - } - if _, doubled := data["text"]; doubled { - t.Error("the table is said twice: once as the output and again inside the data") - } - asks, _ := data["asks"].([]any) - if len(asks) != 4 || data["waiting"] != float64(2) { - t.Fatalf("data: %v", data) - } - second := asks[2].(map[string]any) - if second["position"] != float64(2) || second["kind"] != "check" || second["commit"] != aCommit { - t.Errorf("second waiting: %v", second) - } - for _, a := range asks { - if _, held := a.(map[string]any)["held"]; held { - t.Error("an ask carries what the mesh holds") - } - } - // A failure is said as text, as before. - failed := answerOf(argv, nil, "mesh-controller: the bus cannot be reached\n", false) - if failed.Answer != nil || !strings.Contains(failed.Output, "cannot be reached") { - t.Errorf("a failure: %+v", failed) - } -} - -// A repository named with a password never shows it, in the table or the data. -func TestTheQueueNeverShowsARepositorysPassword(t *testing.T) { - if got := link.WithoutPassword("https://agent:s3cret@forge.example/novox/x.git"); strings.Contains(got, "s3cret") || - !strings.Contains(got, "agent@forge.example/novox/x.git") { - t.Errorf("redacted: %s", got) - } - now := time.Now() - q := link.Queue{Seat: link.TheBuildMachine, Asks: []link.QueuedAsk{{Seq: 1, ID: "build-1", - Repository: "https://agent:s3cret@forge.example/novox/x.git", State: link.AskWaiting}}} - body, _ := json.Marshal(queueAnswerOf(q, now)) - if strings.Contains(string(body), "s3cret") { - t.Errorf("the queue said a password: %s", body) - } - for _, kept := range []string{"ssh://git@forge:222/novox/x.git", "novox/x", "git@forge:novox/x.git", ""} { - if got := link.WithoutPassword(kept); got != kept { - t.Errorf("%q became %q", kept, got) - } - } -} - -// A cancel says it deleted the ask only when it did: moved into order and gone since, it says so (novox/hq -// issue 558). -func TestACancelSaysDeletedOnlyWhenItDeleted(t *testing.T) { - ask := link.QueuedAsk{ID: "build-1", Repository: "novox/x", State: link.AskWaiting, Seq: 4} - if said := cancelSaid(ask, link.TheBuildMachine, 9); !strings.Contains(said, "deleted from the") { - t.Errorf("removed, it says: %s", said) - } - if said := cancelSaid(ask, link.TheBuildMachine, 0); strings.Contains(said, "deleted from") || - !strings.Contains(said, "no longer in the") { - t.Errorf("gone already, it says: %s", said) - } -} diff --git a/cmd/mesh-controller/queue_held_test.go b/cmd/mesh-controller/queue_held_test.go deleted file mode 100644 index 2561492e..00000000 --- a/cmd/mesh-controller/queue_held_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/link" -) - -// Issue 1688 / 1652 AC5 (ADR 0483 clause 5): the queue names, above the waiting checks, the plan they are held for, and says of a -// check let through that it waited past 30 minutes. - -func aHeldQueue(now time.Time) link.Queue { - check := func(id string, ago time.Duration, n int) link.QueuedAsk { - return link.QueuedAsk{Seq: uint64(n), ID: id, Repository: "ssh://forge/novox/r", AskedAt: now.Add(-ago), State: link.AskWaiting, - Request: link.BuildRequest{ID: id, Check: &link.CheckRequest{Owner: "novox", Repo: "r", Number: n}}} - } - return link.Queue{Seat: link.TheBuildMachine, Asks: []link.QueuedAsk{ - check("build-young", 5*time.Minute, 1), - check("build-old", 45*time.Minute, 2), - check("build-older", 50*time.Minute, 3), - }, Hold: link.CheckHold{Plans: []string{"plan-42"}}} -} - -func TestTheQueueNamesThePlanTheChecksAreHeldFor(t *testing.T) { - now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) - text := queueText(aHeldQueue(now), now) - heldAt := strings.Index(text, "held while delivery plan-42 builds") - firstAt := strings.Index(text, "build-young") - if heldAt < 0 || firstAt < 0 || heldAt > firstAt { - t.Fatalf("the plan the checks are held for is not named above the waiting checks:\n%s", text) - } - // Nothing building: nothing held. - q := aHeldQueue(now) - q.Hold = link.CheckHold{} - if strings.Contains(queueText(q, now), "held while") { - t.Fatal("checks are said held with no delivery building") - } -} - -func TestACheckLetThroughIsSaidToHaveWaitedPastThirtyMinutes(t *testing.T) { - now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) - q := aHeldQueue(now) - text := queueText(q, now) - var saidOf []string - for _, l := range strings.Split(text, "\n") { - if strings.Contains(l, "waited past 30 minutes, let through") { - saidOf = append(saidOf, strings.Fields(l)[0]) - } - } - if len(saidOf) != 1 || saidOf[0] != "build-older" { - t.Fatalf("the check let through next (the oldest) is not the one said: %v\n%s", saidOf, text) - } - answer := queueAnswerOf(q, now) - if len(answer.HeldFor) != 1 || answer.HeldFor[0] != "plan-42" { - t.Errorf("held-for = %v", answer.HeldFor) - } - for _, a := range answer.Asks { - if a.LetThrough != (a.ID == "build-older") { - t.Errorf("%s let-through = %v", a.ID, a.LetThrough) - } - } - // A check in flight under the let-through key is said too. - q.Asks = append(q.Asks, link.QueuedAsk{Seq: 9, ID: "build-running", State: link.AskInFlight, On: "ace", AskedAt: now.Add(-time.Hour), - Started: now.Add(-time.Minute), Request: link.BuildRequest{ID: "build-running", Check: &link.CheckRequest{Owner: "novox", Repo: "r", Number: 9}}}) - q.Hold.LetThrough = &link.LetThrough{Builder: "ace"} - if !strings.Contains(queueText(q, now), "build-running") || !strings.Contains(queueText(q, now), "waited past 30 minutes, let through\n") { - t.Errorf("the check in flight let through is not said:\n%s", queueText(q, now)) - } - // Younger than the bound: none is said. - young := aHeldQueue(now) - young.Asks = young.Asks[:1] - if strings.Contains(queueText(young, now), "waited past") { - t.Error("a check of 5 minutes is said to have waited past 30") - } -} diff --git a/cmd/mesh-controller/queue_integration_test.go b/cmd/mesh-controller/queue_integration_test.go deleted file mode 100644 index 5bb334f2..00000000 --- a/cmd/mesh-controller/queue_integration_test.go +++ /dev/null @@ -1,431 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "encoding/json" - "fmt" - "reflect" - "strings" - "testing" - "time" - - "github.com/nats-io/nats.go" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" - "github.com/novox/mesh-controller/internal/testbus" -) - -func TestAFailedPlanIsRetriedAndGoesOnThroughItsLaterTiers(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - asked := asksRecorded(t) - twoTiers(t, open) - before := time.Now().UTC().Add(-time.Hour) - failed := inventory.Plan{ID: "plan-retry", Repository: "novox/a", Branch: "main", Commit: "c0ffee", - Created: before, State: inventory.PlanFailed, Tier: 0, Tiers: [][]string{{"a"}, {"b"}}, - Note: "a failed to build in tier 0", - Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1", - Why: link.KilledByHand}}} - if err := open.inventory.SavePlan(ctx, &failed); err != nil { - t.Fatal(err) - } - - said, err := retryPlan(ctx, open, failed.ID) - if err != nil { - t.Fatal(err) - } - p, err := open.inventory.PlanByID(ctx, failed.ID) - if err != nil { - t.Fatal(err) - } - a := p.Modules["a"] - if p.State != inventory.PlanBuilding || a.State != "asked" || a.Build == "" || a.Build == "build-1" || a.Why != "" { - t.Fatalf("after retry the plan is %s and a is %+v", p.State, a) - } - if !strings.Contains(said, a.Build) { - t.Errorf("retry does not say the new id: %q", said) - } - if len(*asked) != 1 { - t.Fatalf("asked %v", *asked) - } - - // The killed build's own late outcome is not this ask's; the new one's is, and tier 1 follows. - planBuilt(ctx, open, "a", "c0ffee", link.KilledByHand, before, "build-1") - if p, _ = open.inventory.PlanByID(ctx, failed.ID); p.State != inventory.PlanBuilding { - t.Fatalf("the old ask's outcome failed the retried plan: %s %q", p.State, p.Note) - } - asking, _ := link.BuildAskedAt(a.Build) - planBuilt(ctx, open, "a", "c0ffee", "", asking, a.Build) - p, err = open.inventory.PlanByID(ctx, failed.ID) - if err != nil { - t.Fatal(err) - } - if p.Tier != 1 || p.Modules["b"] == nil || p.Modules["b"].State != "asked" || p.Modules["b"].Build == "" { - t.Fatalf("the retried plan did not go on to tier 1: tier %d, %s, b %+v", p.Tier, p.State, p.Modules["b"]) - } - if len(*asked) != 2 { - t.Fatalf("asked %v", *asked) - } -} - -func aBuildQueue(t *testing.T) (*broker.JetStream, func(id, repository string) link.BuildRequest) { - t.Helper() - url := testbus.URL(t) - t.Setenv(broker.NATSVar, url) - js, err := broker.Dial(url) - if err != nil { - t.Fatal(err) - } - t.Cleanup(js.Close) - seat := broker.DeclaredSeat{Name: link.TheBuildMachine, Accepts: []string{"build"}, - Emits: []string{"started", "built", "log.*", "paused.*"}} - if err := broker.AssertMeshStreams(js); err != nil { - t.Fatal(err) - } - _ = js.Context().DeleteStream("SEAT_NODE_BUILD_AGENT") - if err := broker.RaiseSeats(js, []broker.DeclaredSeat{seat}, map[string]broker.Holder{ - link.TheBuildMachine: {Node: "anchor", Module: "build-agent"}}); err != nil { - t.Fatal(err) - } - if err := broker.RaiseCancelledSets(js, []broker.DeclaredSeat{seat}); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = js.Context().DeleteStream("SEAT_NODE_BUILD_AGENT") - _ = js.Context().DeleteKeyValue(broker.CancelledSetName(link.TheBuildMachine)) - _ = js.Context().PurgeStream(broker.EventsStream) - }) - ask := func(id, repository string) link.BuildRequest { - r := link.BuildRequest{ID: id, Repository: repository, Held: map[string]string{"x/y": "secret-ish"}} - body, _ := json.Marshal(r) - if _, err := js.Context().Publish(link.BuildWork(), body); err != nil { - t.Fatal(err) - } - return r - } - return js, ask -} - -func TestCancelDeletesTheAskAndFailsThePlanThatAskedIt(t *testing.T) { - js, ask := aBuildQueue(t) - open := aMesh(t) - ctx := t.Context() - twoTiers(t, open) - id := link.NewBuildID(time.Now()) - ask(id, "https://forge.example/novox/a.git") - asked, _ := link.BuildAskedAt(id) - plan := inventory.Plan{ID: "plan-cancel", Repository: "novox/a", Commit: "c0ffee", Created: asked, - State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}}, - Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked, Build: id}}} - if err := open.inventory.SavePlan(ctx, &plan); err != nil { - t.Fatal(err) - } - - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - if len(q.Asks) != 1 || q.Asks[0].State != link.AskWaiting || q.Asks[0].ID != id { - t.Fatalf("the queue reads %+v", q) - } - if text := queueText(q, time.Now()); !strings.Contains(text, "1 waiting, 0 in flight, 0 dead") || - strings.Contains(text, "secret-ish") { - t.Errorf("the queue says:\n%s", text) - } - - if err := cancelCommand(ctx, []string{id}); err != nil { - t.Fatal(err) - } - if q, _ = link.ReadQueue(ctx, js, link.TheBuildMachine); len(q.Asks) != 0 { - t.Fatalf("the ask is still queued: %+v", q.Asks) - } - if cancelled, err := link.IsCancelled(js.Conn(), link.TheBuildMachine, id); err != nil || !cancelled { - t.Errorf("the cancelled set does not hold it: %v %v", cancelled, err) - } - b, found, err := open.inventory.BuildByID(ctx, id) - if err != nil || !found || b.Failed != link.CancelledByHand { - t.Fatalf("the cancel is recorded as %+v (%v %v)", b, found, err) - } - p, err := open.inventory.PlanByID(ctx, plan.ID) - if err != nil { - t.Fatal(err) - } - if p.State != inventory.PlanFailed || p.Modules["a"].State != "failed" || p.Modules["a"].Why != link.CancelledByHand { - t.Fatalf("the plan that asked is %s, a %+v", p.State, p.Modules["a"]) - } - if err := cancelCommand(ctx, []string{id}); err == nil { - t.Error("an ask cancelled already was cancelled again") - } -} - -func TestClearCancelsTheWaitingAndTheDeadOnlyWhenTold(t *testing.T) { - js, ask := aBuildQueue(t) - open := aMesh(t) - ctx := t.Context() - start := time.Now() - dead := link.NewBuildID(start) - ask(dead, "https://forge.example/novox/dead.git") - deadOne(t, js) - var waiting []string - for i := 1; i <= 2; i++ { - id := link.NewBuildID(start.Add(time.Duration(i) * time.Millisecond)) - waiting = append(waiting, id) - ask(id, fmt.Sprintf("https://forge.example/novox/w%d.git", i)) - } - - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - if len(q.Of(link.AskDead)) != 1 || len(q.Of(link.AskWaiting)) != 2 || q.MaxDeliver != 5 { - t.Fatalf("the queue reads %+v", q) - } - said, err := clearQueue(ctx, js, open, link.TheBuildMachine, false) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(said, "2 waiting ask(s) cancelled") || !strings.Contains(said, "1 dead, left") { - t.Errorf("clear said:\n%s", said) - } - q, _ = link.ReadQueue(ctx, js, link.TheBuildMachine) - if len(q.Asks) != 1 || q.Asks[0].ID != dead || q.Asks[0].State != link.AskDead { - t.Fatalf("after clear the queue is %+v", q.Asks) - } - if _, err := clearQueue(ctx, js, open, link.TheBuildMachine, true); err != nil { - t.Fatal(err) - } - if q, _ = link.ReadQueue(ctx, js, link.TheBuildMachine); len(q.Asks) != 0 { - t.Fatalf("clear --dead left %+v", q.Asks) - } - for _, id := range append(waiting, dead) { - if b, found, _ := open.inventory.BuildByID(ctx, id); !found || b.Failed != link.CancelledByHand { - t.Errorf("%s is recorded as %+v", id, b) - } - } -} - -func TestCancelRefusesAnAskInFlight(t *testing.T) { - js, ask := aBuildQueue(t) - open := aMesh(t) - ctx := t.Context() - id := link.NewBuildID(time.Now()) - ask(id, "https://forge.example/novox/a.git") - 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: id, On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)}) - if _, err := js.Context().Publish(link.BuildStarted(), started); err != nil { - t.Fatal(err) - } - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - a, _ := q.Find(id) - if a.State != link.AskInFlight || a.On != "ace" { - t.Fatalf("the taken ask reads %+v", a) - } - _, err = cancelAsk(ctx, js, open, link.TheBuildMachine, a) - if err == nil || !strings.Contains(err.Error(), "kill "+id) { - t.Fatalf("an ask in flight was cancelled: %v", err) - } - if _, found, _ := open.inventory.BuildByID(ctx, id); found { - t.Error("a refused cancel recorded an outcome") - } -} - -func TestKillAndPauseAskTheHolderOnTheMachine(t *testing.T) { - js, _ := aBuildQueue(t) - ctx := t.Context() - asked := map[string]string{} - handlers := map[string]link.ToolHandler{ - "kill": func(_ context.Context, raw json.RawMessage) (any, error) { - var args struct{ ID string } - _ = json.Unmarshal(raw, &args) - asked["kill"] = args.ID - if args.ID != "build-running" { - return nil, fmt.Errorf("ace is not building %s", args.ID) - } - return map[string]any{"said": "killed " + args.ID}, nil - }, - "pause": func(context.Context, json.RawMessage) (any, error) { - asked["pause"] = "ace" - return map[string]any{"said": "ace is paused"}, nil - }, - } - stop, err := link.OverNATS{Conn: js.Conn()}.ServeNodeSeatTools(link.TheBuildMachine, "ace", handlers, nil) - if err != nil { - t.Fatal(err) - } - defer stop() - for _, id := range []string{"build-running", "build-other"} { - started, _ := json.Marshal(link.BuildStart{ID: id, On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)}) - if _, err := js.Context().Publish(link.BuildStarted(), started); err != nil { - t.Fatal(err) - } - } - if err := killCommand(ctx, []string{"build-running"}); err != nil { - t.Fatal(err) - } - if asked["kill"] != "build-running" { - t.Fatalf("the holder was asked %v", asked) - } - if err := killCommand(ctx, []string{"build-other"}); err == nil { - t.Error("the holder's refusal was not the command's") - } - if err := killCommand(ctx, []string{"build-never"}); err == nil || !strings.Contains(err.Error(), "cancel build-never") { - t.Errorf("a build nobody started: %v", err) - } - if err := pauseCommand(ctx, "pause", []string{"ace"}); err != nil || asked["pause"] != "ace" { - t.Fatalf("pause: %v %v", err, asked) - } - if err := pauseCommand(ctx, "resume", []string{"g14"}); err == nil { - t.Error("a machine nothing answers on was resumed") - } -} - -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) - twoTiers(t, open) - var sentTo [][]string - was := sendRollout - sendRollout = func(_ context.Context, _ *stores, names []string) ([]string, error) { - sentTo = append(sentTo, names) - return names, nil - } - t.Cleanup(func() { sendRollout = was }) - - // a records: a person's choice, since the default rolls out (novox/hq ADR 0236). - if err := open.inventory.SetUpgradeOf(ctx, "a", inventory.Upgrade{Why: "test"}); err != nil { - t.Fatal(err) - } - long := time.Now().UTC().Add(-2 * time.Hour) - 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"}}} - if err := open.inventory.SavePlan(ctx, &stopped); err != nil { - t.Fatal(err) - } - - // A newer plan holding a refuses it: sending the older build would put it back. - newer := inventory.Plan{ID: "plan-newer", Repository: "novox/other", Commit: "d00d", Created: long.Add(time.Hour), - State: inventory.PlanDone, Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{}} - if err := open.inventory.SavePlan(ctx, &newer); err != nil { - t.Fatal(err) - } - if _, err := retryPlan(ctx, open, stopped.ID); err == nil || !strings.Contains(err.Error(), "plan-newer") { - t.Fatalf("retried under a newer plan: %v", err) - } - newer.State = inventory.PlanSuperseded - if err := open.inventory.SavePlan(ctx, &newer); err != nil { - t.Fatal(err) - } - - said, err := retryPlan(ctx, open, stopped.ID) - if err != nil { - t.Fatal(err) - } - if len(sentTo) != 1 || !reflect.DeepEqual(sentTo[0], []string{"laptop"}) || !strings.Contains(said, "laptop") { - t.Fatalf("sent %v; said %q", sentTo, said) - } - p, err := open.inventory.PlanByID(ctx, stopped.ID) - if err != nil { - t.Fatal(err) - } - a := p.Modules["a"] - if p.State != inventory.PlanRolling || a.FirstAt == nil || !a.FirstAt.After(long) || a.Why != "" { - t.Fatalf("after retry the plan is %s, a %+v", p.State, a) - } - // And it goes on: a records (its policy sends nothing more), so the next tier is asked. - advancePlans(ctx, open) - if p, _ = open.inventory.PlanByID(ctx, stopped.ID); p.Tier != 1 || p.Modules["b"] == nil || p.Modules["b"].State != "asked" { - t.Fatalf("the retried plan did not go on: tier %d %s %+v", p.Tier, p.State, p.Modules["b"]) - } - if len(*asked) != 1 { - t.Fatalf("asked %v", *asked) - } -} - -func TestAPlanIsAnsweredOnlyByTheBuildItAskedFor(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - asked := time.Now().UTC().Add(-time.Minute) - plan := inventory.Plan{ID: "plan-own", Repository: "novox/a", Commit: "c0ffee", Created: asked, - State: inventory.PlanBuilding, Tiers: [][]string{{"a"}}, - Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked, Build: "build-own"}}} - if err := open.inventory.SavePlan(ctx, &plan); err != nil { - t.Fatal(err) - } - planBuilt(ctx, open, "a", "0ldc0mm1t", "", time.Now().UTC(), "build-replay") - p, _ := open.inventory.PlanByID(ctx, plan.ID) - if p.Modules["a"].State != "asked" { - t.Fatalf("a replay asked after the plan settled it: %+v", p.Modules["a"]) - } - // From the records too: a later build of the module recorded is not the plan's. - recorded := map[string][]inventory.Build{"a": {{ID: "build-replay", Commit: "0ldc0mm1t", Asked: time.Now(), At: time.Now()}}} - if settleFromRecords(&p, p.Tiers[0], recorded, nil) { - t.Fatalf("the records settled it with another build: %+v", p.Modules["a"]) - } - planBuilt(ctx, open, "a", "c0ffee", "", asked, "build-own") - if p, _ = open.inventory.PlanByID(ctx, plan.ID); p.Modules["a"].State != "built" || p.Modules["a"].Commit != "c0ffee" { - t.Fatalf("its own build did not settle it: %+v", p.Modules["a"]) - } -} - -func TestCancelOfAnAskNobodySaidIsWithdrawnWhenItStarts(t *testing.T) { - js, ask := aBuildQueue(t) - open := aMesh(t) - ctx := t.Context() - was := holderLooks - holderLooks = 300 * time.Millisecond - t.Cleanup(func() { holderLooks = was }) - id := link.NewBuildID(time.Now()) - ask(id, "https://forge.example/novox/a.git") - 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) - } - q, err := link.ReadQueue(ctx, js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - a, _ := q.Find(id) - if a.State != link.AskInFlight || a.On != "" { - t.Fatalf("the taken ask reads %+v", a) - } - // The holder that took it says it started, while the cancel waits. - go func() { - time.Sleep(100 * time.Millisecond) - started, _ := json.Marshal(link.BuildStart{ID: id, On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)}) - _, _ = js.Context().Publish(link.BuildStarted(), started) - }() - if _, err := cancelAsk(ctx, js, open, link.TheBuildMachine, a); err == nil || !strings.Contains(err.Error(), "started on ace") { - t.Fatalf("a build that started was cancelled: %v", err) - } - if cancelled, _ := link.IsCancelled(js.Conn(), link.TheBuildMachine, id); cancelled { - t.Error("the withdrawn cancel is still marked") - } - if _, found, _ := open.inventory.BuildByID(ctx, id); found { - t.Error("a withdrawn cancel recorded an outcome") - } -} diff --git a/cmd/mesh-controller/queue_test.go b/cmd/mesh-controller/queue_test.go deleted file mode 100644 index c5a4e281..00000000 --- a/cmd/mesh-controller/queue_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package main - -import ( - "context" - "reflect" - "strings" - "testing" - "time" - - "github.com/nats-io/nats.go" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// The build queue, controlled by hand (novox/hq ADR 0219), and the plans that follow it. -// -// make postgres PG_PORT=55566 PG_CONTAINER=bq-pg -// MESH_TEST_POSTGRES='postgres://postgres:check@127.0.0.1:55566/postgres?sslmode=disable' \ -// go test ./cmd/mesh-controller/ -run 'Queue|Cancel|Clear|Retry|Rebuild|Replay|Paused' (each test on a bus of its own) - -// asksRecorded makes every ask a plan makes return the next id in a row, and says which were asked. -func asksRecorded(t *testing.T) *[]string { - t.Helper() - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): these tests are of what a walk does around it - var asked []string - was := askABuild - askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) { - id := link.NewBuildID(time.Now().Add(time.Duration(len(asked)) * time.Millisecond)) - asked = append(asked, source.Repository+"#"+id) - return id, nil - } - t.Cleanup(func() { askABuild = was }) - return &asked -} - -// twoTiers registers two modules, b standing on a, each with a source a plan asks. -func twoTiers(t *testing.T, open *stores) { - t.Helper() - for _, name := range []string{"a", "b"} { - if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: name, Version: "1"}, - inventory.Source{Repository: "novox/" + name, Seat: "git", Ref: "main", BuiltFrom: "c0ffee", Head: "c0ffee"}); err != nil { - t.Fatal(err) - } - } -} - -// A failed plan is retried: its failed module asked again under a new id, the plan building at that -// tier, and when that build comes in the plan goes on and asks its next tier. - -// What retry refuses, and says why. -func TestRetryRefusesWhatItCannotResume(t *testing.T) { - at := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - plan := func(id, state string, created time.Time) inventory.Plan { - return inventory.Plan{ID: id, Repository: "novox/mesh-catalog", Branch: "main", Commit: id + "c0ffee", - Created: created, State: state, Tiers: [][]string{{"a"}}, - Modules: map[string]*inventory.PlanModule{"a": {State: "failed"}}} - } - failed := plan("plan-1", inventory.PlanFailed, at) - for _, c := range []struct { - p inventory.Plan - others []inventory.Plan - says string - }{ - {plan("plan-d", inventory.PlanDone, at), nil, "is done"}, - {plan("plan-s", inventory.PlanSuperseded, at), nil, "was"}, - {plan("plan-o", inventory.PlanBuilding, at), nil, "still building"}, - {failed, []inventory.Plan{plan("plan-2", inventory.PlanRolling, at.Add(time.Hour))}, "plan-2 supersedes it"}, - } { - err := retryRefusal(c.p, c.others, ownPathOf(nil)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v, wanted it to say %q", c.p.ID, err, c.says) - } - } - stopped := failed - stopped.Modules = map[string]*inventory.PlanModule{"a": {State: "built"}} - stopped.Note = "a stopped at its first machine" - if err := retryRefusal(stopped, nil, ownPathOf(nil)); err == nil || !strings.Contains(err.Error(), "nothing in tier 0") { - t.Errorf("a plan with nothing failed to build was retried: %v", err) - } - // A later failed plan does not supersede it; a later done one passes it (novox/hq ADR 0451). - if err := retryRefusal(failed, []inventory.Plan{plan("plan-4", inventory.PlanFailed, at.Add(time.Hour))}, ownPathOf(nil)); err != nil { - t.Errorf("refused for a plan that does not supersede it: %v", err) - } - if err := retryRefusal(failed, []inventory.Plan{plan("plan-5", inventory.PlanDone, at.Add(time.Hour))}, ownPathOf(nil)); err == nil || - !strings.Contains(err.Error(), "plan-5") { - t.Errorf("a plan a later done plan passed was retried: %v", err) - } - // Another branch's open walk, or an older one, does not supersede it, and is one walk open: one at a time - // (novox/hq ADR 0276). - other := plan("plan-3", inventory.PlanBuilding, at.Add(time.Hour)) - other.Branch = "release" - for _, open := range []inventory.Plan{other, plan("plan-0", inventory.PlanBuilding, at.Add(-time.Hour))} { - if err := retryRefusal(failed, []inventory.Plan{open}, ownPathOf(nil)); err == nil || !strings.Contains(err.Error(), "one walk at a time") { - t.Errorf("retried beside the open walk %s: %v", open.ID, err) - } - } -} - -// `rebuild` of a module a failed plan holds joins that plan; one held by nothing runs alone. - -// A plan waiting on builds of a seat whose every holder is paused says so and is not late; a seat -// paused on some holders only is not a reason the plan is waiting. -func TestAPlanWaitingOnAPausedSeatSaysSoAndIsNotLate(t *testing.T) { - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - asked := now.Add(-12 * time.Minute) - p := inventory.Plan{ID: "plan-p", Repository: "novox/a", Commit: "c0ffee", State: inventory.PlanBuilding, - Updated: now.Add(-2 * time.Hour), Tiers: [][]string{{"a"}}, - Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked}}} - - all := pauseOf([]string{"g14", "ace"}, map[string]link.HolderState{"ace": {Paused: true}, "g14": {Paused: true}}) - line := planLineWith(p, now, all, tierAtLeast) - if !strings.Contains(line, "waiting: the build seat is paused on ace, g14 (asked 12m0s ago)") || strings.Contains(line, "LATE") { - t.Errorf("a plan on a paused seat reads %q", line) - } - if st := planStatuses([]inventory.Plan{p}, now, all, nil)[0]; st.Late || !strings.Contains(st.Waiting, "paused on ace, g14") { - t.Errorf("status --json says %+v", st) - } - if _, late := openPlans([]inventory.Plan{p}, now, all, nil); late != 0 { - t.Errorf("a plan waiting on a paused seat counted late") - } - - longAgo := now.Add(-25 * time.Hour) - forgotten := p - forgotten.Modules = map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &longAgo}} - if line := planLineWith(forgotten, now, all, tierAtLeast); !strings.Contains(line, "PAUSED OVER A DAY") || strings.Contains(line, "LATE") { - t.Errorf("a plan paused over a day reads %q", line) - } - - some := pauseOf([]string{"g14", "ace"}, map[string]link.HolderState{"ace": {Paused: true}}) - if some.All || !reflect.DeepEqual(some.Nodes, []string{"ace"}) { - t.Fatalf("%+v", some) - } - if line := planLineWith(p, now, some, tierAtLeast); !strings.Contains(line, "LATE") { - t.Errorf("a seat paused on one holder of two made the plan not late: %q", line) - } - if st := planStatuses([]inventory.Plan{p}, now, some, nil)[0]; !st.Late { - t.Errorf("status --json: %+v", st) - } - // A plan rolling out, or with nothing asked, is not waiting on the seat. - rolling := p - rolling.State = inventory.PlanRolling - if _, paused := pausedWaiting(rolling, all, now); paused { - t.Error("a rolling plan reads as waiting on the build seat") - } -} - -// The queue's verbs are the controller seat's, each to the command it names. -func TestTheQueueVerbsRunTheirCommands(t *testing.T) { - for _, c := range []struct { - verb string - args map[string]any - want []string - }{ - {"queue", nil, []string{"queue", "--json"}}, - {"cancel", map[string]any{"id": "build-1"}, []string{"cancel", "build-1"}}, - {"clear", nil, []string{"clear"}}, - {"clear", map[string]any{"dead": "true"}, []string{"clear", "--dead"}}, - {"replay", map[string]any{"id": "build-1"}, []string{"replay", "build-1"}}, - {"kill", map[string]any{"id": "build-1"}, []string{"kill", "build-1"}}, - {"pause", nil, []string{"pause"}}, - {"resume", map[string]any{"node": "ace"}, []string{"resume", "ace"}}, - {"plans", map[string]any{"retry": "plan-1"}, []string{"plans", "retry", "plan-1"}}, - } { - got, err := argvFor(c.verb, c.args) - if err != nil || !reflect.DeepEqual(got, c.want) { - t.Errorf("%s %v: %v %v, want %v", c.verb, c.args, got, err, c.want) - } - } - if _, err := argvFor("cancel", nil); err == nil { - t.Error("cancel without an id was taken") - } - declared := map[string]bool{} - for _, v := range catalogue.ControllerVerbs { - declared[v.Name] = true - } - for _, v := range []string{"queue", "cancel", "clear", "rebuild", "replay", "kill", "pause", "resume"} { - if !declared[v] { - t.Errorf("%s is not a verb of the controller seat", v) - } - } -} - -// --- against a real bus ----------------------------------------------------------------------- - -// aBuildQueue is the build seat's queue, worker and cancelled set on a real server, the controller -// pointed at it, and a function that asks the seat one build. - -// deadOne takes the oldest ask from the worker and hands it back as often as the worker allows. -func deadOne(t *testing.T, js *broker.JetStream) { - t.Helper() - 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() }() - for i := 0; i < worker.MaxDeliver; i++ { - msgs, err := sub.Fetch(1, nats.MaxWait(3*time.Second)) - if err != nil { - t.Fatalf("delivery %d: %v", i+1, err) - } - _ = msgs[0].Nak() - } - // One more pull, as a holder always has one waiting: the server finds the ask past its deliveries - // then, and stops counting it pending. - if msgs, _ := sub.Fetch(1, nats.MaxWait(time.Second)); len(msgs) > 0 { - t.Fatalf("an ask past its deliveries was delivered again") - } -} - -// cancel drops a waiting ask from the bus and records it failed, cancelled by hand — and the plan -// that asked for it, matched by the id, fails with it; an ask the plan's records name no module for -// is still found. - -// clear cancels every waiting ask and leaves the dead ones unless told, and never one in flight. - -// An ask in flight is not cancelled: kill ends it where it runs. - -// kill finds the machine from the build's start and asks that machine's holder; pause asks the -// machine named. Each prints what the holder answered, and a refusal is the command's failure. - -// 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. - -// A plan module asked under an id is settled by that id's outcome alone: a replay or a rebuild beside -// the plan, asked later, never answers it (novox/hq ADR 0219). - -// rebuild of a build made at a commit asks what the module follows now, never the commit. - -// An ask the worker counts out that no machine said it started is cancelled only if no start comes -// while a holder looks: one that does withdraws the cancel, and kill is what ends it. diff --git a/cmd/mesh-controller/readable.go b/cmd/mesh-controller/readable.go index 30a32f0d..3d037c68 100644 --- a/cmd/mesh-controller/readable.go +++ b/cmd/mesh-controller/readable.go @@ -252,7 +252,7 @@ func statusAsJSON(asked answers) ([]byte, error) { out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{}, Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{}, Reported: []machineReported{}, Unresolved: []machineUnresolved{}, - Network: asked.network, Adopted: adoptedNodes(nodes), Plans: planStatuses(asked.plans, time.Now(), asked.paused, asked.tierBounds), + Network: asked.network, Adopted: adoptedNodes(nodes), Plans: planStatuses(asked.plans, time.Now(), asked.tierBounds), NotAnnounced: asked.notAnnounced, NotAnnouncedWhy: asked.notAnnouncedWhy} // In a stated order, so two readings of an unchanged mesh are the same document. untakenNodes := make([]string, 0, len(asked.untaken)) diff --git a/cmd/mesh-controller/references.go b/cmd/mesh-controller/references.go index c313d7e2..0486964b 100644 --- a/cmd/mesh-controller/references.go +++ b/cmd/mesh-controller/references.go @@ -18,23 +18,6 @@ import ( // given. Nothing recorded carries a port, so moving the store is a settings change and not a // rebuild of everything the mesh has ever built. -// recordedManifest is a build's manifest with the store's address taken off every reference the -// build itself made. Other references — an image a module runs from a public registry — are what -// they were, which is why this rewrites only what `made` names rather than everything that looks -// like an address. -func recordedManifest(raw json.RawMessage, made []inventory.Artifact) json.RawMessage { - announced := map[string]bool{} - for _, a := range made { - announced[a.Reference] = true - } - return withReferences(raw, func(ref string) (string, bool) { - if !announced[ref] { - return ref, false - } - return catalogue.Recorded(ref), true - }) -} - // routedManifest is a recorded manifest with the store's address, as this network reaches it now, // composed into every reference the build made — recorded either way, before or after references // were kept without their address. @@ -118,33 +101,3 @@ func whereTheStoreIs(ctx context.Context, inv *inventory.Inventory, forNode stri } return artifactStoreAddress(ctx, inv, shelf, forNode) } - -// whereABuilderReachesTheStore is the store's address for the machine that builds: the network's -// when there is one, else loopback on the store's own node — when that node also holds a module -// requiring the store, which is what a builder is (genesis: one node holds both). -func whereABuilderReachesTheStore(ctx context.Context, inv *inventory.Inventory) (string, error) { - shelf, err := inv.Catalogue(ctx) - if err != nil { - return "", err - } - holder, _, found, err := artifactStoreHolder(ctx, inv) - if err != nil || !found { - return "", err - } - assigned, err := inv.Assigned(ctx, holder) - if err != nil { - return "", err - } - besideIt := false - for _, a := range assigned { - for _, r := range shelf[a].Requires { - if r == catalogue.ArtifactStoreProvision { - besideIt = true - } - } - } - if !besideIt { - holder = "" - } - return artifactStoreAddress(ctx, inv, shelf, holder) -} diff --git a/cmd/mesh-controller/register.go b/cmd/mesh-controller/register.go index 1c7a727b..02b04285 100644 --- a/cmd/mesh-controller/register.go +++ b/cmd/mesh-controller/register.go @@ -3,11 +3,17 @@ package main import ( "context" "errors" + "flag" "fmt" + "regexp" + "sort" "strings" + "git.novox.be/novox/mesh-sdk/go/catalogue" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-controller/internal/artifacts" "github.com/novox/mesh-controller/internal/builderkeys" + "github.com/novox/mesh-controller/internal/inventory" ) // The terminal's exception becomes a registration, not a build (novox/hq ADR 0501 §4; issue 1851, A11b). @@ -17,16 +23,49 @@ import ( // indexer's trunk value of that module names — the registration a build asked at the terminal used to make for a // repository the catalogue does not build (ADR 0432 §3, ADR 0420 §7). No verb reaches it (terminalOnlyCommands), and it // asks no build: a key not done is waited for, and said. +// +// **With `--reference =`** (ADR 0526 clause 3; issue 1888), repeatable, the module is registered at +// references the artifact store holds instead of a builder's key: the hand path for a controller, builder or build module +// the builder cannot build (the R4g fallback). The references replace or add the artifacts of the builder's key when it +// has one; every artifact the manifest declares must end up named. It is recorded as a build by hand, so the store's +// sweep holds the blobs, and in the hand-act log with its `--why`. func registerCommand(ctx context.Context, args []string) error { - if len(args) != 3 { - return errors.New("register / (the path \".\" for a module at the repository's root)") + set := flag.NewFlagSet("register", flag.ContinueOnError) + references := map[string]string{} + var given []string + set.Func("reference", "=: register the module at this artifact, which the store holds, "+ + "instead of a builder's key (repeatable; needs --why)", func(v string) error { + artifact, reference, err := parseReference(v) + if err != nil { + return err + } + if _, twice := references[artifact]; twice { + return fmt.Errorf("--reference names %s twice", artifact) + } + references[artifact] = reference + given = append(given, v) + return nil + }) + hand := addHandActFlags(set) + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if len(positionals) != 3 { + return errors.New("register / [--reference = ... --why ] " + + "(the path \".\" for a module at the repository's root)") } if !startedAtTheTerminal() { return terminalRefusal("register is run at the controller's terminal only, never through a verb: it registers " + "a module from a repository the catalogue does not build, and whoever may call a verb includes agents " + - "(novox/hq ADR 0266, ADR 0501 §4). Nothing was done") + "(novox/hq ADR 0266, ADR 0501 §4, ADR 0526 §3). Nothing was done") } - module, repository, path := args[0], strings.Trim(args[1], "/"), strings.Trim(args[2], "/") + if len(references) > 0 { + if err := hand.require("register --reference"); err != nil { + return err + } + } + module, repository, path := positionals[0], strings.Trim(positionals[1], "/"), strings.Trim(positionals[2], "/") if path == sdkdelivery.RootPath { path = "" } @@ -40,6 +79,20 @@ func registerCommand(ctx context.Context, args []string) error { return err } defer closeKeys() + return registerNow(ctx, open, keys, hand, module, repository, path, references, given) +} + +// registerKeys is what a registration reads of the indexer and the builder (builderkeys.Reader, or a test's). +type registerKeys interface { + keyReads + ModuleAt(ctx context.Context, module, commit string) (sdkdelivery.Module, error) +} + +// registerNow is the registration once its arguments are read and the terminal is known: the module registered from its +// key, or at the references given. **The hand act is written after the registration, and only when it was made** (ADR +// 0526 clause 3): a take-in that waited or was refused did nothing by hand. +func registerNow(ctx context.Context, open *stores, keys registerKeys, hand handActFlags, module, repository, path string, + references map[string]string, given []string) error { trunk, err := keys.ModuleAt(ctx, module, "") if errors.Is(err, builderkeys.ErrNoValue) { return fmt.Errorf("the indexer holds no trunk value of %s: a module is registered from the builder's key of a "+ @@ -51,8 +104,16 @@ func registerCommand(ctx context.Context, args []string) error { if err := sameModuleSource(module, repository, path, trunk); err != nil { return err } - manifest, kept, err := takeInKey(ctx, open.inventory, keys, keyTake{Module: module, Trunk: trunk, - Ask: registrationAsk{Terminal: true}}) + take := keyTake{Module: module, Trunk: trunk, Ask: registrationAsk{Terminal: true}} + if len(references) > 0 { + // Every reference is one the store holds, before anything is recorded: a registration at bytes that are not + // there would be sent to the machines and fail there (ADR 0526 clause 3). + if err := handStoreHoldsEvery(ctx, open.inventory, module, references); err != nil { + return err + } + take.Hand = references + } + manifest, kept, err := takeInKey(ctx, open.inventory, keys, take) if errors.Is(err, errKeyWaits) { return fmt.Errorf("%s is not registered yet, and nothing was asked: %v. Run register again once its key is done", module, err) @@ -60,11 +121,92 @@ func registerCommand(ctx context.Context, args []string) error { if err != nil { return err } + if take.Hand != nil { + // Written down with its why (novox/hq to-be 45 §7) now that it is done: a placement by hand is not undone by the + // next push, and the log says who put it there. + act := []string{module, repository, positionalPath(path)} + for _, g := range given { + act = append(act, "--reference", g) + } + hand.record(ctx, "register", act) + fmt.Printf("%s %s registered at the references given (%s at %.8s), as asked at the controller's terminal, "+ + "recorded as a build by hand; assigned nowhere\n", manifest.Module, manifest.Version, repository, kept.Commit) + return nil + } fmt.Printf("%s %s registered from the builder's key %s (%s at %.8s), as asked at the controller's terminal; "+ "assigned nowhere\n", manifest.Module, manifest.Version, short(kept.SourceFingerprint), repository, kept.Commit) return nil } +// positionalPath is the path as the command was given it: "." for a module at the repository's root. +func positionalPath(path string) string { + if path == "" { + return sdkdelivery.RootPath + } + return path +} + +// handDigestOf is the sha256 digest a recorded reference ends in. +var handDigestOf = regexp.MustCompile(`sha256:[0-9a-f]{64}$`) + +// parseReference reads one `--reference =`: a reference into the mesh's artifact store, by digest. +func parseReference(v string) (artifact, reference string, err error) { + artifact, reference, ok := strings.Cut(v, "=") + artifact, reference = strings.TrimSpace(artifact), strings.TrimSpace(reference) + if !ok || artifact == "" || reference == "" { + return "", "", fmt.Errorf("--reference %q is not =", v) + } + if _, ours := catalogue.InArtifactStore(reference); !ours { + return "", "", fmt.Errorf("--reference %s: %q is not a reference into the artifact store (%s...)", artifact, + reference, catalogue.ArtifactStoreScheme) + } + if !handDigestOf.MatchString(reference) { + return "", "", fmt.Errorf("--reference %s: %q names no digest (it ends in sha256:<64 hex digits>)", artifact, reference) + } + return artifact, reference, nil +} + +// handStoreHolds says whether the artifact store holds what a recorded reference names; a variable so a test needs no store. +var handStoreHolds = func(ctx context.Context, inv *inventory.Inventory, reference string) error { + shelf, err := inv.Catalogue(ctx) + if err != nil { + return err + } + address, err := artifactStoreAddress(ctx, inv, shelf, "") + if err != nil { + return err + } + if address == "" { + return errors.New("the artifact store is on no network the controller reaches") + } + return artifacts.Store{Address: address}.Holds(ctx, reference) +} + +// handStoreHoldsEvery refuses a reference that names another module's repository, or bytes the store does not hold. +func handStoreHoldsEvery(ctx context.Context, inv *inventory.Inventory, module string, references map[string]string) error { + names := make([]string, 0, len(references)) + for artifact := range references { + names = append(names, artifact) + } + sort.Strings(names) + for _, artifact := range names { + reference := references[artifact] + if path, _ := catalogue.InArtifactStore(reference); !strings.HasPrefix(path, module+"/") { + return fmt.Errorf("--reference %s: %s is not in %s's repository of the store. Nothing was registered", + artifact, reference, module) + } + if err := handStoreHolds(ctx, inv, reference); err != nil { + if errors.Is(err, artifacts.Gone) { + return fmt.Errorf("--reference %s: the artifact store does not hold %s. Nothing was registered", artifact, + reference) + } + return fmt.Errorf("--reference %s: the artifact store could not say whether it holds %s: %w. Nothing was "+ + "registered", artifact, reference, err) + } + } + return nil +} + // sameModuleSource refuses a registration whose repository or path is not the one the indexer's trunk value names: the // operator names exactly what is registered (ADR 0501 §4). func sameModuleSource(module, repository, path string, trunk sdkdelivery.Module) error { diff --git a/cmd/mesh-controller/register_hand_integration_test.go b/cmd/mesh-controller/register_hand_integration_test.go new file mode 100644 index 00000000..bb9ad644 --- /dev/null +++ b/cmd/mesh-controller/register_hand_integration_test.go @@ -0,0 +1,86 @@ +//go:build integration + +package main + +import ( + "context" + "flag" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" + "github.com/novox/mesh-controller/internal/builderkeys" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// C8b (ADR 0526 clause 3; issue 1888): `register --reference` writes its hand act, with the why, only once the module is +// registered; a take-in that waits writes none. +func TestARegistrationAtReferencesWritesItsHandActOnlyWhenItWasMade(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + b := aBuilderOnTheBus(t) + if err := b.js.EnsureControllerBuckets(); err != nil { + t.Fatal(err) + } + api, err := jetstream.New(b.js.Conn()) + if err != nil { + t.Fatal(err) + } + keys := builderkeys.Reader{JS: api} + fp := fpOf('8') + b.indexed(t, "sudo", "7", "novox/mesh-catalog", "modules/sudo", "c0ffee0123456789", fp) + ref := "artifact-store://sudo/a/blobs/" + shaOf("hand") + // The limit of this test: the artifact store is not on its bus, so the check that the store holds the digest is + // stood in for (handStoreHolds, tried by TestAReferenceToRegisterAtIsRefusedUnlessTheStoreHoldsIt); everything after + // it - the record, the registration, the hand act - runs for real. + was := handStoreHolds + handStoreHolds = func(context.Context, *inventory.Inventory, string) error { return nil } + t.Cleanup(func() { handStoreHolds = was }) + set := flag.NewFlagSet("register", flag.ContinueOnError) + hand := addHandActFlags(set) + if err := set.Parse([]string{"--why", "the builder cannot build it"}); err != nil { + t.Fatal(err) + } + acts := func() []link.HandAct { + got, err := link.HandActs(ctx, b.js.Conn(), time.Now().Add(-time.Minute)) + if err != nil { + t.Fatal(err) + } + return got + } + given := []string{"a=" + ref} + + withTrunkList(t, false) // the list at the catalogue's trunk head cannot be read: the take-in waits + err = registerNow(ctx, open, keys, hand, "sudo", "novox/mesh-catalog", "modules/sudo", map[string]string{"a": ref}, given) + if err == nil || len(acts()) != 0 { + t.Fatalf("a take-in that waited: %v, and %d hand act(s)", err, len(acts())) + } + + withTrunkList(t, true) + if err := registerNow(ctx, open, keys, hand, "sudo", "novox/mesh-catalog", "modules/sudo", map[string]string{"a": ref}, given); err != nil { + t.Fatal(err) + } + recorded, err := inv0(open).Builds(ctx, "sudo", 1) + if err != nil || len(recorded) != 1 || recorded[0].On != "hand" || !strings.HasPrefix(recorded[0].ID, "hand-key-") || + recorded[0].Module != "sudo" || len(recorded[0].Made) != 1 || recorded[0].Made[0].Reference != ref { + t.Fatalf("the build recorded by hand: %+v %v", recorded, err) + } + if byID, found, err := inv0(open).BuildByID(ctx, recorded[0].ID); err != nil || !found || byID.Made[0].Reference != ref { + t.Fatalf("the record is not read back by its id: %+v %v %v", byID, found, err) + } + // Recorded as a build, so the store's sweep holds the blob (internal/artifacts/hold.go). + if keep, err := inv0(open).KeepSet(ctx); err != nil || len(keep[ref]) == 0 { + t.Fatalf("the sweep would let go of the blob registered by hand: %v %v", keep[ref], err) + } + if shelf, _ := inv0(open).Catalogue(ctx); shelf["sudo"].Version != "7" { + t.Fatalf("sudo is registered at version %q, not the manifest's", shelf["sudo"].Version) + } + got := acts() + if len(got) != 1 || got[0].Verb != "register" || got[0].Why != "the builder cannot build it" { + t.Fatalf("the hand act of a registration made: %+v", got) + } +} + +func inv0(open *stores) *inventory.Inventory { return open.inventory } diff --git a/cmd/mesh-controller/register_test.go b/cmd/mesh-controller/register_test.go index 99b1e45f..301e2f6b 100644 --- a/cmd/mesh-controller/register_test.go +++ b/cmd/mesh-controller/register_test.go @@ -7,6 +7,8 @@ import ( "testing" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-controller/internal/artifacts" + "github.com/novox/mesh-controller/internal/inventory" ) // A11b (ADR 0501 §4): the terminal's registration names exactly the module's repository and path the indexer holds, @@ -36,3 +38,92 @@ func TestTheTerminalsRegistrationNamesItsSourceExactlyAndNoVerbReachesIt(t *test t.Fatalf("register through a verb: %v", err) } } + +// C8 (ADR 0526 clause 3; issue 1888): a reference is one into the artifact store, by digest; the store must hold it. +func TestAReferenceToRegisterAtIsRefusedUnlessTheStoreHoldsIt(t *testing.T) { + good := "artifact-store://x/tools/blobs/" + shaOf("t") + if a, r, err := parseReference("tools=" + good); err != nil || a != "tools" || r != good { + t.Fatalf("a good reference: %q %q %v", a, r, err) + } + for _, bad := range []string{"tools", "=" + good, "tools=", "tools=docker.io/library/x@" + shaOf("t"), + "tools=artifact-store://x/tools/blobs/latest"} { + if _, _, err := parseReference(bad); err == nil { + t.Errorf("%q is taken", bad) + } + } + held := map[string]bool{good: true} + was := handStoreHolds + t.Cleanup(func() { handStoreHolds = was }) + handStoreHolds = func(_ context.Context, _ *inventory.Inventory, reference string) error { + if held[reference] { + return nil + } + return artifacts.Gone + } + if err := handStoreHoldsEvery(t.Context(), nil, "x", map[string]string{"tools": good}); err != nil { + t.Fatalf("a held reference is refused: %v", err) + } + gone := "artifact-store://x/tools/blobs/" + shaOf("other") + if err := handStoreHoldsEvery(t.Context(), nil, "x", map[string]string{"tools": gone}); err == nil || + !strings.Contains(err.Error(), "does not hold") { + t.Errorf("a digest the store does not hold: %v", err) + } + if err := handStoreHoldsEvery(t.Context(), nil, "x", map[string]string{"tools": "artifact-store://y/tools/blobs/" + shaOf("t")}); err == nil { + t.Error("another module's repository is taken") + } +} + +const handManifest = `{"module":"x","version":"1","tools":["x_read"], + "build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["dist/index.js"]}]}}` + +// C8: a module with no key at all is registered at the references given, recorded as a build by hand under an id of its +// own; an artifact the manifest does not declare, or a declared one nothing names, is refused. +func TestAModuleIsTakenInAtReferencesWithNoKey(t *testing.T) { + keys, trunk := aKeyOf(handManifest, 1) + keys.keys = nil + ref := "artifact-store://x/tools/blobs/" + shaOf("t") + kept, resolved, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk, Hand: map[string]string{"tools": ref}}, keyContext{}) + if err != nil { + t.Fatal(err) + } + if kept.On != "hand" || !strings.HasPrefix(kept.ID, "hand-") || len(kept.Made) != 1 || kept.Made[0].Reference != ref || + kept.Made[0].Kind != "bundle" || len(resolved.Bundles) != 1 { + t.Errorf("the build by hand: %+v %+v", kept, resolved.Bundles) + } + if _, _, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk, Hand: map[string]string{"nope": ref}}, + keyContext{}); err == nil || !strings.Contains(err.Error(), "declares no artifact") { + t.Errorf("an undeclared artifact: %v", err) + } + if _, _, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk, Hand: map[string]string{}}, + keyContext{}); err == nil || !strings.Contains(err.Error(), "no --reference names them") { + t.Errorf("a declared artifact nothing names: %v", err) + } +} + +// C8: the builder's key, when there is one, supplies what the references do not name. +func TestAHandReferenceReplacesOneArtifactOfTheBuildersKey(t *testing.T) { + keys, trunk := aKeyOf(tsBundle, 2, + sdkdelivery.BuiltArtifact{Name: "tools", Kind: "bundle", Digest: shaOf("t"), Reference: "artifact-store://x/tools/blobs/" + shaOf("t")}, + sdkdelivery.BuiltArtifact{Name: "img", Kind: "image", Digest: shaOf("i"), Reference: "artifact-store://x/img@" + shaOf("i")}) + hand := "artifact-store://x/tools/blobs/" + shaOf("hand") + kept, _, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk, Hand: map[string]string{"tools": hand}}, keyContext{}) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, m := range kept.Made { + got[m.Name] = m.Reference + } + if got["tools"] != hand || got["img"] != "artifact-store://x/img@"+shaOf("i") || len(got) != 2 { + t.Errorf("Made: %v", got) + } +} + +// C8b (ADR 0526 clause 3): a registration at references refuses without a why, before anything is opened or done. +func TestARegistrationAtReferencesRefusesWithoutAWhy(t *testing.T) { + ref := "artifact-store://x/tools/blobs/" + shaOf("t") + err := registerCommand(context.Background(), []string{"x", "someone/app", ".", "--reference", "tools=" + ref}) + if err == nil || !strings.Contains(err.Error(), "--why") || !strings.Contains(err.Error(), "Nothing was done") { + t.Fatalf("a registration at references with no why: %v", err) + } +} diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 54336966..fa254d87 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -6,7 +6,6 @@ import ( "errors" "flag" "fmt" - sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "os" "slices" "sort" @@ -17,7 +16,6 @@ import ( "github.com/nats-io/nats.go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/chain" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" @@ -381,147 +379,6 @@ func askedInTier(p inventory.Plan) bool { 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 - } - // **A module taken from the builder's key is answered by its key alone** (ADR 0501 §1): a build-agent outcome of it - // is somebody else's build. - if state.Key { - 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. -// -// Set in init, not where it is declared: an assignment pending on a build asks for one too (novox/hq issue -// 325), and a build's outcome makes pending assignments, so the two refer to each other. -var askABuild func(ctx context.Context, source buildSource, path, ref string) (string, error) - -func init() { - askABuild = func(ctx context.Context, source buildSource, path, ref string) (string, error) { - // **Only the builder's own module is asked of build-agent** (novox/hq ADR 0453 §5, ADR 0498 §7; issue 1851, - // A12): refused here, in the one function every ask goes through, whoever calls it. - if err := onlyTheBuildersOwn(ctx, source, path); err != nil { - return "", err - } - // Kept by each asker with its own name once the ask is made (novox/hq issue 325). - return buildOneAsked(ctx, source, path, ref, 0, false, "") - } -} - -// errNotTheBuildersOwn says an ask of build-agent was refused: from R4e build-agent builds the builder's own module -// alone, and every other is taken from the builder's done key. -var errNotTheBuildersOwn = errors.New("build-agent is asked for the builder's own module alone (ADR 0453 §5)") - -// askedModuleOf is the module the catalogue builds from a source and path, empty when it builds none from it. A variable -// so a test needs no store. -var askedModuleOf = func(ctx context.Context, source buildSource, path string) (string, error) { - open, err := openStores(ctx) - if err != nil { - return "", err - } - defer open.Close() - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - return "", err - } - forms := newSourceForms(ctx, open.inventory) - for _, e := range entries { - if forms.same(e.Source.Repository, e.Source.Seat, source.Repository, source.Seat) && - strings.Trim(e.Source.Path, "/") == strings.Trim(path, "/") { - return e.Manifest.Module, nil - } - } - return "", nil -} - -// onlyTheBuildersOwn refuses an ask of any module but the builder's own, naming the key it is taken from instead. -func onlyTheBuildersOwn(ctx context.Context, source buildSource, path string) error { - module, err := askedModuleOf(ctx, source, path) - if err != nil { - return fmt.Errorf("%w: which module %s at %q is cannot be read, so nothing is asked: %v", errNotTheBuildersOwn, - source, orRoot(path), err) - } - if isBuildersOwn(module) { - return nil - } - // **While no running builder says it writes references, build-agent builds every module** (issue 1886): judged by the - // say the walk read when it has one, read here only for an ask that carries none. - says, read := builderSayOf(ctx) - if !read { - var err error - if says, err = builderSaysReferences(ctx); err != nil { - says = true // not known: refused below, as before the say existed - } - } - if !says { - return nil - } - if module == "" { - module = fmt.Sprintf("%s at %q", source, orRoot(path)) - } - return fmt.Errorf("%w: %s is taken from the builder's done key of its fingerprint in %s (ADR 0501), and no build "+ - "of it is asked", errNotTheBuildersOwn, module, sdkdelivery.BuilderBuildsBucket) -} - -// pullsOfWalk are the pull requests of the merges a walk answers, each once: what its end lets go of (forgetChecksOfWalk) -// and what a check of one was (same_program.go). -func pullsOfWalk(p *inventory.Plan) []link.PullRef { - if p.Delivery == nil { - return nil - } - var out []link.PullRef - seen := map[link.PullRef]bool{} - for _, m := range p.Delivery.Merges { - pull, ok := pullOf(m) - if !ok { - continue - } - if !seen[pull] { - seen[pull] = true - out = append(out, pull) - } - } - return out -} - -// checkedBuildsOf are the builds the build seat's checks of those pull requests said they made for their merges, -// within the week check builds are kept (novox/hq ADR 0350 §2): read from the seat's own outcomes on the bus. A -// variable so a test needs no bus. -var checkedBuildsOf = func(ctx context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) { - if len(pulls) == 0 { - return nil, nil - } - js, err := aBus() - if err != nil { - return nil, err - } - defer js.Close() - return link.ReadCheckBuilds(ctx, js, buildSeatHeld(ctx), pulls, time.Now().Add(-builder.CheckBuildsKept)) -} - -// pullOf is a merge's pull request: owner and repository, the last two parts of however the repository is spelled -// (owner/repo, a path on the git seat, a URL), and its number. False when the merge names none. -func pullOf(m inventory.PlanMerge) (link.PullRef, bool) { - parts := strings.Split(strings.Trim(strings.TrimSuffix(m.Repository, ".git"), "/"), "/") - if len(parts) < 2 || m.Number <= 0 || parts[len(parts)-2] == "" || parts[len(parts)-1] == "" { - return link.PullRef{}, false - } - return link.PullRef{Owner: parts[len(parts)-2], Repo: parts[len(parts)-1], Number: m.Number}, true -} - // askModule asks the build machine for one module of a plan and marks it asked, with the id it was // asked under (novox/hq ADR 0219) — or failed, with the plan, when it could not be asked. func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[string]inventory.Entry) { @@ -539,108 +396,9 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s p.Note = name + " is no longer in the catalogue" return } - // **Every module but the builder's own is taken from the builder's done key, asking no build** (novox/hq ADR 0503 - // §6, ADR 0501, ADR 0453 §5): the look settles it (settleFromKeys). - // **Only while a running builder says it writes references** (issue 1886): until then the keys it writes could never - // be delivered from, and the module is asked of build-agent as before R4e. A say not read leaves it unasked, said, - // for the next look. - if !isBuildersOwn(name) { - says, err := builderSaysReferences(ctx) - if err != nil { - state.Why = "waits: whether the builder writes references cannot be read: " + firstLine(err.Error()) - return - } - if says { - sayBuilding(ctx, p.ID, now) - takeFromKey(p, state, e, now) - return - } - // The ask's guard judges by this say, never by a second read that could fail or differ (review of issue 1886). - ctx = withBuilderSay(ctx, says) - } - source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat} - fmt.Printf(" tier %d: ", p.Tier) - // The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215): the branch contains every - // commit a batch's walk carries — but for a merge walked alone after a failed walk (novox/hq ADR 0276 - // decision 3), built on its own commit to find which merge brought the failure. - ref := followedBranch(e.Source.Ref) - carried := p.CommitOn(e.Source.Repository, ref) - if p.Delivery != nil && p.Delivery.Alone && carried != "" { - ref = carried - } - // **The plan's key is there before its first build is** (ADR 0483 clause 2): a check taken between the ask and the plan's save - // would hold a place for its whole run. - sayBuilding(ctx, p.ID, now) - id, err := askABuild(ctx, source, e.Source.Path, ref) - if err != nil { - // **A bus that does not answer for a moment is not a failed build** (issue 1334): the module stays unasked and the - // tier's next look asks it again. A fault of the module's own fails the plan at once, as does a bus silent too long. - reason := err.Error() - if busDidNotAnswer(err) { - if waitForTheBus(p, state, name, err, now) { - return - } - reason = fmt.Sprintf("the bus did not answer an ask for %s (since %s): %s", busWaitBound, - state.BusWaitFrom.UTC().Format(time.RFC3339), firstLine(err.Error())) - } - // The wait has ended: a Retry or a rebuild waits for the bus afresh. - state.BusWaitFrom = nil - state.State = "failed" - state.Why = reason - p.State = inventory.PlanFailed - p.Note = fmt.Sprintf("%s could not be asked for: %s", name, reason) - return - } - state.BusWaitFrom = nil - // The commit of the module's own repository the walk carries (novox/hq ADR 0276): a batch's walk carries - // one per repository. - commit := carried - if commit == "" { - commit = p.Commit - } - recordAsked(ctx, inventory.BuildRequest{ID: id, Repository: e.Source.Repository, Seat: e.Source.Seat, - Path: e.Source.Path, Ref: followedBranch(e.Source.Ref), Commit: commit, For: "plan"}) - state.State = "asked" - state.AskedAt = &now - state.Build = id - state.Why, state.Commit, state.BuiltAt, state.Made = "", "", nil, "" -} - -// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and -// advances what that completes. Called from the daemon's take-in of every outcome. -// -// **Only a build asked at or after the plan's ask is its outcome** (novox/hq 04-ISSUES/219). Two -// plans a few minutes apart both ask for a module; the earlier plan's build, finishing late, is not -// the later plan's answer — it stood on the bases from before the later plan's merge, and taking it -// would send machines, and the next tier, what the later merge replaced. asked is zero when the -// build's request time is not known, and such an outcome is taken as before. -func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id string) { - planBuiltSaying(ctx, open, module, commit, failed, asked, id, "") -} - -// holdPlansForOutcome is how an outcome taken on the event loop holds the plans: **without waiting for them** -// (novox/hq issue 1708). The loop takes one message at a time, and the plans' holder may keep them for minutes while -// it sends machines their declarations; an outcome that waited its turn (up to inventory.HoldWaitFor, two minutes) -// held every report, build and merge behind it for as long, and the watchdog said the controller stopped listening -// (2026-10-21 14:20:45 to 14:22:45). Busy, the outcome is left to the build records, where it already is, and the -// look settles the plan from them (leaveToRecords). -func holdPlansForOutcome(ctx context.Context, hold func(context.Context, bool) (func(), error)) (func(), error) { - return hold(ctx, false) -} - -// leaveToRecords says an outcome was not taken into the plans, which settle it from the build records (novox/hq issue -// 214). When another act holds the plans it asks for the look, which waits for them off the event loop and settles -// the plan from the records once they are let go (issue 1708); what the build did is kept for that settle by the -// build's id, so the walk's module says it as it would have (ADR 0350 §4). -func leaveToRecords(module, id, made, failed string, err error) { - fmt.Printf("plans: %s's outcome is left to the build records: %v\n", module, err) - if !errors.Is(err, inventory.ErrPlansBusy) { - return - } - keepMadeFor(id, made, time.Now()) - // A refused outcome (failed, its build recorded as worked) is kept as refused, or the records settle it built. - keepRefusalFor(id, failed, time.Now()) - askToLook() + // **Every module is taken from the builder's done key, asking no build** (novox/hq ADR 0503 §6, ADR 0501, ADR 0453 §5; + // issue 1851): the look settles it (settleFromKeys). A key not done yet waits, named in the plan, and never fails it. + takeFromKey(p, state, e, now) } // refusalsKeptFor is the refusals kept in this process for these builds (keepRefusalFor), by id. @@ -693,22 +451,6 @@ type madeKept struct { // madeLeftFor is how long words nobody settled are kept: a plan that ended or moved on never asks for them. const madeLeftFor = time.Hour -// keepMadeFor keeps what a build left to the records did for the settle that takes it, forgetting words older than -// madeLeftFor. -func keepMadeFor(id, made string, now time.Time) { - if id == "" || made == "" { - return - } - madeLeftMu.Lock() - defer madeLeftMu.Unlock() - for k, v := range madeLeft { - if now.Sub(v.at) > madeLeftFor { - delete(madeLeft, k) - } - } - madeLeft[id] = madeKept{made: made, at: now} -} - // takeMadeOf is what the build of this id did, if its outcome was left to the records with words; taken once. func takeMadeOf(id string) string { madeLeftMu.Lock() @@ -721,97 +463,6 @@ func takeMadeOf(id string) string { return k.made } -// planBuiltSaying is planBuilt keeping what the build did — "reused from " or "built: " (novox/hq -// ADR 0350 §4) — on the module of the walk it answers. -func planBuiltSaying(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id, made string) { - inv := open.inventory - // One controller works the plans at a time (novox/hq issue 213). Not taken, the outcome is still in the build - // records, which the holder settles the plan from (issue 214). - release, err := holdPlansForOutcome(ctx, inv.HoldPlans) - if err != nil { - if failed != "" && id != "" && errors.Is(err, inventory.ErrPlansBusy) { - // In the store too, so a controller replaced before the look settles still fails the plan (issue 1708). - if err := inv.RecordRefused(ctx, id, module, failed); err != nil { - fmt.Printf("plans: %s's refusal could not be kept in the store, only in this controller: %v\n", module, err) - } - } - leaveToRecords(module, id, made, failed, err) - return - } - defer release() - plans, err := inv.OpenPlans(ctx) - if err != nil { - fmt.Printf("plans: cannot read them: %v\n", err) - return - } - now := time.Now().UTC() - for i := range plans { - p := &plans[i] - if p.Tier >= len(p.Tiers) { - continue - } - inTier := false - for _, m := range p.Tiers[p.Tier] { - if m == module { - inTier = true - } - } - if !inTier { - continue - } - // **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) { - continue - } - if failed != "" && deletedAtSource(failed) { - // Deleted at its source by the merge, not broken (novox/hq ADR 0236): the plan goes on. - state.State, state.Why = planDeleted, "deleted at its source: "+firstLine(failed) - forgetDeleted(ctx, inv, module, p) - } else if failed != "" { - state.State = "failed" - state.Why = failed - p.State = inventory.PlanFailed - p.Note = fmt.Sprintf("%s failed to build in tier %d", module, p.Tier) - sayUnsent(p, func(m string) bool { - u, err := inv.UpgradeOf(ctx, m) - return err == nil && u.RollOut - }) - } else { - state.State = "built" - state.BuiltAt = &now - state.Commit = commit - state.Made = made - } - if err := inv.SavePlan(ctx, p); err != nil { - fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err) - continue - } - if p.State == inventory.PlanFailed { - fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note) - } - } - advanceAfterOutcome(func() { advanceHeld(ctx, open) }) -} - -// advanceAfterOutcome moves the plans after an outcome was taken into them: **in the serving controller, by asking -// the look** (novox/hq issue 1708). The outcome is taken on the event loop, and moving the plans may send machines -// their declarations, a release's send retried for minutes among them; done here it held every report, build and -// merge behind it (2026-10-21 14:28:40 to 14:32:10). The look runs off the loop, holding the plans itself. Where no -// look runs (a command, which has no event loop) the plans are moved at once, as before. -func advanceAfterOutcome(advance func()) { - if askedToLook() { - return - } - advance() -} - // startedBeside is the id of a started walk open beside this one that it is related to — a merge's walk past // its wait, not the backlog's — with why, or empty: a walk waits only for a started walk it shares something with // (novox/hq ADR 0276 decision 4, ADR 0407 decision 4, issue 973). @@ -2001,7 +1652,7 @@ func sayUnsent(p *inventory.Plan, rollsOut func(string) bool) { // planLine is one plan as `status` says it, given the least bound of a tier. func planLine(p inventory.Plan, now time.Time) string { - return planLineWith(p, now, pauseView{}, tierAtLeast) + return planLineWith(p, now, tierAtLeast) } // inTierSince is when a plan began waiting on what it waits on now, which `plans`, `status` and the @@ -2025,23 +1676,22 @@ func inTierSince(p inventory.Plan) time.Time { return since } -// planLineWith is planLine knowing whether the build seat is paused (novox/hq ADR 0219): a plan -// waiting on builds nobody will take until a person resumes the seat says so, and is not late. bound is +// planLineWith is planLine with bound, the plan's tier bound: bound is // the plan's tier bound, the one its stalled condition is raised at (tierBounds): LATE is that condition // said on the line (novox/hq issue 296). The machines running what it moves are not named: planLineOn. -func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration) string { - return planLineOn(p, now, pause, bound, nil) +func planLineWith(p inventory.Plan, now time.Time, bound time.Duration) string { + return planLineOn(p, now, bound, nil) } // planLineOn is planLineWith naming the plan by what it moves and where (planHeadline), given what runs each // module; nil names no machine. -func planLineOn(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string) string { - return planLineSaid(p, now, pause, bound, running, nil) +func planLineOn(p inventory.Plan, now time.Time, bound time.Duration, running func(string) []string) string { + return planLineSaid(p, now, bound, running, nil) } // planLineSaid is planLineOn saying, of a walk waiting for its delivery's word, what the delivery's owner says // holds it (novox/hq issue 529); nil waits says it as before. -func planLineSaid(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string, +func planLineSaid(p inventory.Plan, now time.Time, bound time.Duration, running func(string) []string, waits walkWaits) string { name := planHeadline(p, running) where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers)) @@ -2068,9 +1718,6 @@ func planLineSaid(p inventory.Plan, now time.Time, pause pauseView, bound time.D if waitingOnBackups(p) { return fmt.Sprintf("%s · %s, %s, for %s", name, where, p.Note, since) } - if waiting, paused := pausedWaiting(p, pause, now); paused { - return fmt.Sprintf("%s · %s, %s", name, where, waiting) - } late := "" if since > bound { late = " — LATE" @@ -2141,51 +1788,6 @@ func planHeadline(p inventory.Plan, running func(string) []string) string { return out } -// planFailedBuild marks the module a failed build was for when the result names no module: by the -// repository and path the plan's modules were asked at. -// -// **By the id first** (novox/hq ADR 0219): a plan keeps the id it asked each module under, so an -// outcome that never learnt its module's name — cancelled, killed, failed at the clone — is matched -// to the module it was asked for exactly. Repository and path remain for a plan from before ids -// were kept. -func planFailedBuild(ctx context.Context, open *stores, result link.BuildResult) { - asked, _ := link.BuildAskedAt(result.ID) - if plans, err := open.inventory.OpenPlans(ctx); err == nil { - if module := moduleAskedAs(plans, result.ID); module != "" { - planBuilt(ctx, open, module, result.Commit, result.Failed, asked, result.ID) - return - } - } - entries, err := open.inventory.Catalogued(ctx) - if err != nil { - return - } - for _, e := range entries { - if repositoryMatches(e.Source.Repository, result.Repository) && e.Source.Path == result.Path { - planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed, asked, result.ID) - return - } - } -} - -// moduleAskedAs is the module an open plan's current tier asked for under this id, or nothing. -func moduleAskedAs(plans []inventory.Plan, id string) string { - if id == "" { - return "" - } - for _, p := range plans { - if p.Tier >= len(p.Tiers) { - continue - } - for _, m := range p.Tiers[p.Tier] { - if s := p.Modules[m]; s != nil && s.Build == id { - return m - } - } - } - return "" -} - func repositoryMatches(a, b string) bool { trim := func(s string) string { return strings.ToLower(strings.TrimSuffix(s, ".git")) } return trim(a) == trim(b) || strings.HasSuffix(trim(a), "/"+trim(b)) || strings.HasSuffix(trim(b), "/"+trim(a)) @@ -2204,7 +1806,7 @@ type planStatus struct { Late bool `json:"late"` } -func planStatuses(plans []inventory.Plan, now time.Time, pause pauseView, bounds tierBounds) []planStatus { +func planStatuses(plans []inventory.Plan, now time.Time, bounds tierBounds) []planStatus { out := make([]planStatus, 0, len(plans)) for _, p := range plans { ps := planStatus{ID: p.ID, Repository: p.Repository, Commit: p.Commit, State: p.State, @@ -2217,10 +1819,6 @@ func planStatuses(plans []inventory.Plan, now time.Time, pause pauseView, bounds } // A walk waiting for its delivery's word is no tier late (ADR 0239): S16 is its watchdog. ps.Late = !p.Waiting() && now.Sub(ps.Since) > bounds.of(p.Repository) - // Paused is a person's decision, not lateness (novox/hq ADR 0219). - if waiting, paused := pausedWaiting(p, pause, now); paused { - ps.Waiting, ps.Late = waiting, false - } } out = append(out, ps) } @@ -2228,13 +1826,13 @@ func planStatuses(plans []inventory.Plan, now time.Time, pause pauseView, bounds } // openPlans is the open plans among the recent ones, and how many have waited past the bound. -func openPlans(plans []inventory.Plan, now time.Time, pause pauseView, bounds tierBounds) ([]inventory.Plan, int) { +func openPlans(plans []inventory.Plan, now time.Time, bounds tierBounds) ([]inventory.Plan, int) { var open []inventory.Plan late := 0 for _, p := range plans { if p.Open() { open = append(open, p) - if _, paused := pausedWaiting(p, pause, now); paused || p.Waiting() { + if p.Waiting() { continue } if now.Sub(inTierSince(p)) > bounds.of(p.Repository) { @@ -2293,7 +1891,7 @@ func plansCommand(ctx context.Context, args []string) error { return err } bounds := readTierBounds(ctx, inv, now) - fmt.Printf("%s — %s\n", p.ID, planLineSaid(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}), + fmt.Printf("%s — %s\n", p.ID, planLineSaid(p, now, bounds.of(p.Repository), func(module string) []string { on, _ := inv.Running(ctx, module) return on @@ -2485,7 +2083,6 @@ func plansCommand(ctx context.Context, args []string) error { fmt.Printf("%-28s %s\n", "", line) } } - pause := buildSeatPause(ctx, inv, plans) bounds := readTierBounds(ctx, inv, now) running := func(module string) []string { on, _ := inv.Running(ctx, module) @@ -2497,7 +2094,7 @@ func plansCommand(ctx context.Context, args []string) error { if p.Batch() { continue } - fmt.Printf("%-28s %s%s\n", p.ID, planLineSaid(p, now, pause, bounds.of(p.Repository), running, waits), heldSaid(p, held)) + fmt.Printf("%-28s %s%s\n", p.ID, planLineSaid(p, now, bounds.of(p.Repository), running, waits), heldSaid(p, held)) } return nil } diff --git a/cmd/mesh-controller/replay867_test.go b/cmd/mesh-controller/replay867_test.go deleted file mode 100644 index 0ed17dbc..00000000 --- a/cmd/mesh-controller/replay867_test.go +++ /dev/null @@ -1,330 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/nats-io/nats.go" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - meshfacts "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/builder" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// TestReplay867 is the replay of novox/hq issue 867, written with only what the controller and the build seat had -// before the fix, so mesh-lab's prover can lay it over the commit before. A mesh-host change (#114) and a -// mesh-controller change (#346) of one delivery group: the node-engine change's own check reads the controller's lock -// beside it, which only the controller change brings. It was read beside the other's repository at the commit the mesh -// runs, which holds neither change, so the node-engine change could not pass its own merge check. (The controller -// change once needed the node-engine's validator word too; the validator is mesh-sdk's now, issue 1391.) -// -// It runs the whole path: the forge announces each pull request (following.PullUpdated), the controller asks -// mesh-delivery which pull requests the group holds, the ask is queued for the build seat, and the build seat's check -// (builder.Check, with a container runtime that runs each container's command on this machine) judges it. - -// TestReplay867Recheck is the same pair re-checked through mesh-delivery (`delivery check` with one member): the forge's -// announcement is not repeated, so the pull request reaches the controller without its head branch name. - -func replay867(t *testing.T, recheck bool) { - if _, err := exec.LookPath("go"); err != nil { - t.Skip("no Go toolchain on this machine to build a judge with") - } - // The check's floor of free space (novox/hq issue 1834) reads the disk the test runs on, which is none of this - // replay's business: a build node under the floor failed every mesh-controller check here (issue 1834, note 12). - wasFree := builder.FreeBytes - builder.FreeBytes = func(string) (uint64, error) { return 500 << 30, nil } - t.Cleanup(func() { builder.FreeBytes = wasFree }) - r := theGroupOfIssue867(t) - for _, c := range []struct { - what, repo string - number int - head string - module string - }{{"the node-engine change", "mesh-host", 114, r.hostHead, "mesh-host"}, - {"the controller change", "mesh-controller", 346, r.controllerHead, "mesh-controller"}} { - v := r.checked(t, c.repo, c.number, c.head, recheck) - if v.Gate == nil || v.Gate.Verdict != "pass" || v.Repo == nil || v.Repo.Verdict != "pass" { - t.Errorf("%s (%s#%d) did not pass its own merge check beside its group: gate %+v, repository %+v\n%s", - c.what, c.repo, c.number, v.Gate, v.Repo, v.Report) - } - } -} - -// theIssue867 is the two repositories as the trunk holds them and each pull request's head. -type theIssue867 struct { - open *stores - hostHead, controllerHead string - js interface{ Conn() *nats.Conn } - judgeCache string - registry string - hostRepo, controllerRepo string - groups string // what mesh-delivery's groups answers -} - -func r867Git(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)) -} - -func r867Commit(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) - } - } - r867Git(t, dir, "add", "-A") - r867Git(t, dir, "commit", "--quiet", "-m", "x") - return r867Git(t, dir, "rev-parse", "HEAD") -} - -// r867Controller is a controller whose judge says who it is and the validator word it was built with. -func r867Controller(who, extra string) map[string]string { - return map[string]string{ - "go.mod": "module example.org/judge\n\ngo 1.22\n\nrequire git.novox.be/novox/mesh-sdk/go v0.0.0\n", - "vendor/modules.txt": "# git.novox.be/novox/mesh-sdk/go v0.0.0\n## explicit; go 1.22\ngit.novox.be/novox/mesh-sdk/go/validate\n", - "vendor/git.novox.be/novox/mesh-sdk/go/validate/v.go": "package validate\n\nconst Word = \"the vendored validator\"\n", - "cmd/mesh-controller/main.go": `package main - -import ( - "fmt" - "os" - - "git.novox.be/novox/mesh-sdk/go/validate" -) - -func main() { - if len(os.Args) == 2 && os.Args[1] == "merge-gate" { - fmt.Println("usage: merge-gate --facts --store ") - os.Exit(2) - } - fmt.Printf("{\"verdict\":\"pass\",\"summary\":\"%s with %s\"}\n", "` + who + `", validate.Word` + extra + `) -} -`, - } -} - -func theGroupOfIssue867(t *testing.T) *theIssue867 { return theGroupOfIssue867Into(t, "main") } - -// theGroupOfIssue867Into is the group with both pull requests into base: main, or a release branch both repositories -// hold, cut from their trunks. -func theGroupOfIssue867Into(t *testing.T, base string) *theIssue867 { - t.Helper() - open := aMesh(t) - r := &theIssue867{open: open, judgeCache: t.TempDir()} - root := filepath.Join(t.TempDir(), "novox") - controller, host := filepath.Join(root, "mesh-controller"), filepath.Join(root, "mesh-host") - r.hostRepo, r.controllerRepo = host, controller - for _, d := range []string{controller, host} { - if err := os.MkdirAll(d, 0o755); err != nil { - t.Fatal(err) - } - r867Git(t, d, "init", "--quiet", "-b", "main") - } - // The trunks. - files := r867Controller("the controller", "") - files["lock.txt"] = "foundation: old\n" - files["module.json"] = `{"module":"mesh-controller"}` - files[builder.CheckScript] = "true\n" - r867Commit(t, controller, files) - r867Commit(t, host, map[string]string{"validate/v.go": "package validate\n\nconst Word = \"the trunk's validator\"\n", - "internal/declaration/d.go": "package declaration\n", "module.json": `{"module":"mesh-host"}`, - builder.CheckScript: "set -e\ngrep -q 'foundation: new' \"$MESH_CHECK_BESIDE/mesh-controller/lock.txt\"\n"}) - if base != "main" { - for _, d := range []string{controller, host} { - r867Git(t, d, "branch", base, "main") - } - } - // The two pull requests of one group, feat/867. - r867Git(t, controller, "checkout", "--quiet", "-b", "feat/867", base) - change := r867Controller("the controller", "") - change["lock.txt"] = "foundation: new\n" - r.controllerHead = r867Commit(t, controller, change) - r867Git(t, controller, "checkout", "--quiet", "main") - r867Git(t, host, "checkout", "--quiet", "-b", "feat/867", base) - r.hostHead = r867Commit(t, host, map[string]string{"notes.txt": "the node-engine change\n"}) - r867Git(t, host, "checkout", "--quiet", "main") - - followed := func(string) string { return "main" } - for _, m := range []struct{ module, dir string }{{"mesh-controller", controller}, {"mesh-host", host}} { - // The mesh runs each at its trunk's head, the commit a check reads it at beside another repository's change. - running := r867Git(t, m.dir, "rev-parse", "main") - if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: m.module, Version: "1"}, - inventory.Source{Repository: m.dir, Ref: followed(m.module), BuiltFrom: running, Head: running}); err != nil { - t.Fatal(err) - } - } - if base != "main" { - // A module built from the controller's repository follows the release branch, as the mesh runs it there: a pull - // request into the release branch moves it, so the composed check's gate runs. - running := r867Git(t, controller, "rev-parse", "main") - if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: "zz-agent", Version: "1"}, - inventory.Source{Repository: controller, Ref: base, BuiltFrom: running, Head: running}); err != nil { - t.Fatal(err) - } - } - holdTheDeliverySeat(t, open) - - // mesh-delivery says the group: two open pull requests into main, each with its head. - was := askDeliveryOwner - t.Cleanup(func() { askDeliveryOwner = was }) - askDeliveryOwner = func(_ context.Context, _ *nats.Conn, verb string, _ map[string]any) (json.RawMessage, error) { - if verb != "groups" { - t.Fatalf("asked the owner %s", verb) - } - return json.RawMessage(r.groups), nil - } - r.groups = (`[{"id":"feat/867","members":["` + root + `/mesh-host@` + r.hostHead[:12] + ` (checked)","` + - root + `/mesh-controller@` + r.controllerHead[:12] + ` (checked)"],"member_details":[` + - `{"id":"novox/mesh-host@` + r.hostHead[:12] + `","repository":"novox/mesh-host","state":"checked","number":114,"head":"` + - r.hostHead + `","base":"` + base + `","open":true},` + - `{"id":"novox/mesh-controller@` + r.controllerHead[:12] + `","repository":"novox/mesh-controller","state":"checked","number":346,"head":"` + - r.controllerHead + `","base":"` + base + `","open":true}]}]`) - r.registry = r867Registry(t) - r867Runtime(t) - return r -} - -// checked is a pull request announced by the forge, asked of the build seat by the controller, and checked there. -func (r *theIssue867) checked(t *testing.T, repo string, number int, head string, recheck bool) builderVerdict { - t.Helper() - js, ask := aBuildQueue(t) - _ = ask - servingBus.Store(js) - t.Cleanup(func() { servingBus.Store(nil) }) - p := link.PullUpdated{Owner: "novox", Repo: repo, Number: number, Base: "main", Head: "feat/867", Commit: head, - Paths: []string{"lock.txt", "notes.txt", "cmd/mesh-controller/main.go"}, ModuleDirsSaid: true, - MergeCheck: true, MergeCheckSaid: true} - if recheck { - // As `delivery check --members ` builds it from mesh-delivery's member: no head branch name. - p = orderMember{ID: "novox/" + repo + "@" + head[:12], Repository: "novox/" + repo, Base: "main", Head: head, - Number: number, Paths: p.Paths, ModuleDirsSaid: true}.pull() - p.MergeCheck, p.MergeCheckSaid = true, true - } - if err := (following{open: r.open, oneShot: true}).PullUpdated(t.Context(), p); err != nil { - t.Fatal(err) - } - q, err := link.ReadQueue(t.Context(), js, link.TheBuildMachine) - if err != nil { - t.Fatal(err) - } - if len(q.Asks) == 0 { - t.Fatalf("%s#%d: nothing was asked of the build seat", repo, number) - } - request := q.Asks[len(q.Asks)-1].Request - // What the build seat makes of the ask: the fields the two share, by name. - var spec builder.CheckSpec - body, _ := json.Marshal(request.Check) - if err := json.Unmarshal(body, &spec); err != nil { - t.Fatal(err) - } - spec.ID, spec.Repository, spec.Ref = request.ID, request.Repository, request.Ref - spec.Toolchain, spec.JudgeCache = "golang", r.judgeCache - v, err := builder.Check(t.Context(), builder.Command, spec, t.TempDir(), r.registry, builder.GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - return v -} - -type builderVerdict = builder.CheckVerdict - -func r867Registry(t *testing.T) string { - t.Helper() - body, err := json.Marshal(meshfacts.Facts{Format: meshfacts.Format, Taken: time.Now().UTC(), - Versions: meshfacts.Versions{Bus: "2.11.17", Store: "17.11"}, Machines: []meshfacts.Machine{{Name: "abcdef", Length: 6}}}) - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(body) - digest := "sha256:" + hex.EncodeToString(sum[:]) - manifest, _ := json.Marshal(map[string]any{"schemaVersion": 2, "layers": []map[string]any{ - {"mediaType": meshfacts.MediaType, "digest": digest, "size": len(body)}}}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - switch { - case strings.Contains(req.URL.Path, "/manifests/"): - w.Write(manifest) - case strings.HasSuffix(req.URL.Path, "/blobs/"+digest): - w.Write(body) - default: - http.NotFound(w, req) - } - })) - t.Cleanup(srv.Close) - return strings.TrimPrefix(srv.URL, "http://") -} - -// r867Runtime 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, and a store and a bus that answer. -func r867Runtime(t *testing.T) { - t.Helper() - 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() - script := `#!/bin/sh -cmd="$1"; shift -case "$cmd" in -ps|rm|exec) exit 0 ;; -port) echo "` + bus.Addr().String() + `"; exit 0 ;; -run) - 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 - cd "$workdir" && exec "$@" ;; -esac -exit 1 -` - if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("GOTOOLCHAIN", "local") -} diff --git a/cmd/mesh-controller/repository_words.go b/cmd/mesh-controller/repository_words.go new file mode 100644 index 00000000..c520ecb9 --- /dev/null +++ b/cmd/mesh-controller/repository_words.go @@ -0,0 +1,22 @@ +package main + +import "strings" + +// orRoot names a module's directory for a sentence: the repository's root when it is empty. +func orRoot(dir string) string { + if dir == "" { + return "the repository's root" + } + return dir +} + +// ownerOf and repoOf are the two halves of an owner/repository name. +func ownerOf(repository string) string { + owner, _, _ := strings.Cut(repository, "/") + return owner +} + +func repoOf(repository string) string { + _, repo, _ := strings.Cut(repository, "/") + return repo +} diff --git a/cmd/mesh-controller/restarted_test.go b/cmd/mesh-controller/restarted_test.go deleted file mode 100644 index 380ffa37..00000000 --- a/cmd/mesh-controller/restarted_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/link" -) - -// **A check that ran again says so on the pull request** (novox/hq issue 594): a build agent replaced -// mid-check leaves the ask to be handed out again, and the verdict that finally comes is from a second -// run. Each layer's summary — what the forge shows as the check's line — names the restart. -func TestARestartedCheckSaysSoOnEveryLayer(t *testing.T) { - asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-controller", Number: 279} - gate := &link.CheckLayer{Verdict: "pass", Summary: "every node composes"} - repo := &link.CheckLayer{Verdict: "pass", Summary: "merge-check.sh passed"} - c := checkedOf(link.BuildResult{ID: "b", Ref: "abc", On: "shanks", Checked: asked, Delivery: 2, - Check: &link.CheckOutcome{Verdict: "pass", Summary: "every node composes", Gate: gate, RepoCheck: repo}}) - for name, said := range map[string]string{"summary": c.Summary, "gate": c.Gate.Summary, - "repository check": c.RepoCheck.Summary} { - // The verdict's own words first, then the marker: the forge clips a status to a few words. - if !strings.HasSuffix(said, " (run 2)") || strings.HasPrefix(said, "(run") { - t.Errorf("the %s says %q: a check run twice reads as run once, or its marker pushes its words out", name, said) - } - } - if !strings.HasPrefix(c.Report, "restarted: handed out 2 times") { - t.Errorf("the report does not open with the restart: %q", c.Report) - } - if gate.Summary != "every node composes" { - t.Errorf("the build's own outcome was changed under it: %q", gate.Summary) - } - - // Taken once, nothing is added. - once := checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Delivery: 1, - Check: &link.CheckOutcome{Verdict: "pass", Summary: "every node composes"}}) - if strings.Contains(once.Summary, "(run") || strings.Contains(once.Report, "restarted") { - t.Errorf("a check run once says %q", once.Summary) - } -} - -// And the queue says it of the ask in flight. -func TestTheQueueSaysAnAskInFlightRestarted(t *testing.T) { - now := time.Date(2026, 10, 13, 0, 16, 0, 0, time.UTC) - q := link.Queue{Seat: link.TheBuildMachine, Delivered: 1, AckPending: 1, MaxDeliver: 5, Asks: []link.QueuedAsk{ - {Seq: 1, ID: "build-279", Repository: "ssh://forge/novox/mesh-controller", AskedAt: now.Add(-7 * time.Minute), - State: link.AskInFlight, On: "shanks", Started: now.Add(-time.Minute), Delivery: 2}, - }} - text := queueText(q, now) - if !strings.Contains(text, "handed out 2 times") { - t.Fatalf("the queue does not say the ask was handed out again:\n%s", text) - } - if a := queueAnswerOf(q, now).Asks[0]; a.Delivery != 2 { - t.Errorf("the queue's data says delivery %d, want 2", a.Delivery) - } -} diff --git a/cmd/mesh-controller/rollout_events_test.go b/cmd/mesh-controller/rollout_events_test.go index 0c072b33..c86e12a4 100644 --- a/cmd/mesh-controller/rollout_events_test.go +++ b/cmd/mesh-controller/rollout_events_test.go @@ -67,18 +67,8 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { // The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept. var mu sync.Mutex - var asks []string var sends [][]string - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - wasAsk := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - mu.Lock() - defer mu.Unlock() - id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond)) - asks = append(asks, source.Repository+"#"+id) - return id, nil - } - t.Cleanup(func() { askABuild = wasAsk }) + asked := asksRecorded(t) // each module taken from a done key as the walk reaches it, said as repository#build id digests := 0 wasSend := sendRollout sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) { @@ -194,7 +184,7 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { eventually(t, "tier 0 sent to both machines and tier 1 asked, on the save alone", func() bool { mu.Lock() defer mu.Unlock() - return len(sends) == 1 && len(asks) == 1 && plan().Tier == 1 + return len(sends) == 1 && len(*asked) == 1 && plan().Tier == 1 }) eventually(t, "a key on the bus for each machine tier 0 was sent to", func() bool { return len(heldKeys()) == 2 }) @@ -216,15 +206,7 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { return !anchor }) - // tier 1's build comes in, as the build machine's outcome is taken in. - b := plan().Modules["b"] - if b == nil || b.Build == "" { - t.Fatalf("tier 1 was not asked: %+v", b) - } - askedAt, _ := link.BuildAskedAt(b.Build) - record(b.Build, "b", "c2", askedAt) - register("b", "c2", askedAt) - planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build) + // tier 1's module is taken from its key by the new controller's look. eventually(t, "tier 1 sent to both machines", func() bool { mu.Lock() defer mu.Unlock() @@ -246,8 +228,8 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { // No tier asked twice and none skipped: one ask for tier 1 (tier 0 was built when the plan was kept), each tier sent // once to both machines, and said as sent once for each. mu.Lock() - if len(asks) != 1 || !strings.HasPrefix(asks[0], "novox/b#") { - t.Errorf("asks = %v; want tier 1's module asked once", asks) + if len(*asked) != 1 || !strings.HasPrefix((*asked)[0], "novox/mesh-catalog#key-") { + t.Errorf("taken from keys = %v; want tier 1's module taken once", *asked) } if len(sends) != 2 || strings.Join(sends[0], ",") != "anchor,laptop" || strings.Join(sends[1], ",") != "anchor,laptop" { t.Errorf("sends = %v; want both machines sent each tier once", sends) diff --git a/cmd/mesh-controller/same_program.go b/cmd/mesh-controller/same_program.go index 00892b95..b044536e 100644 --- a/cmd/mesh-controller/same_program.go +++ b/cmd/mesh-controller/same_program.go @@ -79,7 +79,7 @@ func leaveSameAsRunning(ctx context.Context, inv *inventory.Inventory, plan *inv now, held := map[string]string{}, map[string]string{} for m := range plan.Modules { e, known := byName[m] - if !known || isBuildersOwn(m) { + if !known { continue } v, err := keys.ModuleAt(ctx, m, plan.CommitOn(e.Source.Repository, followedBranch(e.Source.Ref))) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 737c89ec..a4bf2f30 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -58,11 +58,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) { if removedVerbs[verb] { return nil, removedByName(verb) } - if verb == "replay" { - if v, given := args["register"]; given && v != false && v != "false" { - return nil, removedByName("replay --register") - } - } a, err := readArguments(verb, args) if err != nil { return nil, err @@ -99,7 +94,8 @@ type verbArguments struct { // removedVerbs are the controller seat's verbs R4e removed, kept in its table with no input so a caller is answered // by name (ADR 0501 §5, ADR 0503 §3, ADR 0450 §8). -var removedVerbs = map[string]bool{"build": true, "rebuild": true, "forget-checks": true, "delivery-plan": true} +var removedVerbs = map[string]bool{"build": true, "rebuild": true, "forget-checks": true, "delivery-plan": true, + "delivery-check": true, "queue": true, "cancel": true, "clear": true, "kill": true, "replay": true, "pause": true, "resume": true} // controllerVerb is this binary's own definition of a verb: what it runs is what it declares, so the // arguments are checked against the table compiled beside argvFor, not a row a newer or older build @@ -341,8 +337,9 @@ func (a *verbArguments) commandLine() ([]string, error) { case "seats": return []string{"seats", "--json"}, nil case "builds": - if id := str("log"); id != "" { - return []string{"builds", "--log", id}, nil + if str("log") != "" { + return nil, errors.New("a build's log is not read here any more: the build seat is retired (ADR 0525 clause 7), " + + "and a build runs on a build module, whose step key names where it stands (ADR 0501)") } argv := []string{"builds"} if n := str("limit"); n != "" { @@ -427,23 +424,6 @@ func (a *verbArguments) commandLine() ([]string, error) { return nil, err } return append([]string{"delivery", "order"}, members...), nil - case "delivery-check": - if err := need("members"); err != nil { - return nil, err - } - members, err := membersFlags(str("members")) - if err != nil { - return nil, err - } - argv := append([]string{"delivery", "check"}, members...) - if g := str("group"); g != "" { - argv = append([]string{"delivery", "check", "--group", g}, members...) - } - // Marked ready (novox/hq ADR 0406 §3): the check is queued before every other check. - if r := strings.ToLower(str("ready")); r != "" && r != "false" && r != "no" && r != "0" { - argv = append(argv, "--ready") - } - return argv, nil case "deliver": if err := need("plan"); err != nil { return nil, err @@ -484,10 +464,6 @@ func (a *verbArguments) commandLine() ([]string, error) { argv = append(argv, "-n", n) } return argv, nil - // The build queue (novox/hq ADR 0219). - case "queue": - // As data, the table its output (novox/hq issue 455). - return []string{"queue", "--json"}, nil case "try": // A branch tried before its pull request (novox/hq issue 686): asked, or read by the id it answered. if id := str("id"); id != "" { @@ -510,27 +486,6 @@ func (a *verbArguments) commandLine() ([]string, error) { argv = append(argv, "--store-only") } return argv, nil - case "cancel", "kill": - if err := need("id"); err != nil { - return nil, err - } - return []string{verb, str("id")}, nil - case "clear": - if on("dead") { - return []string{"clear", "--dead"}, nil - } - return []string{"clear"}, nil - case "replay": - if err := need("id"); err != nil { - return nil, err - } - // A dry run alone (ADR 0429 §18): register is refused by name before this (argvFor). - return []string{"replay", str("id")}, nil - case "pause", "resume": - if n := str("node"); n != "" { - return []string{verb, n}, nil - } - return []string{verb}, nil case "plan": if err := need("node"); err != nil { return nil, err @@ -1079,8 +1034,6 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co "move-config": true, // The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239. "delivery": true, - // The build queue, each ask with its position among the waiting (novox/hq issue 455). - "queue": true, // A branch tried before its pull request (novox/hq issue 686). "try": true, // A plan followed (novox/hq issue 229): `plans wait` answers JSON; its other forms say text, and so answer no data. @@ -1089,7 +1042,7 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co // tableBesideVerbs are the JSON verbs whose document carries the table a person reads as its `text`: the // verb answers that table as its output, as it did before it answered data, and the rest as `answer` — // both from the one read, so they never disagree (novox/hq issue 455). -var tableBesideVerbs = map[string]bool{"queue": true, "plans": true} +var tableBesideVerbs = map[string]bool{"plans": true} // overviewVerbs are the JSON verbs whose answer is said once, as data, and not again as the text the // command printed: the overviews, which grow with the mesh (novox/hq issue 314). Every reader of theirs @@ -1254,10 +1207,6 @@ func readHere(ctx context.Context, argv []string) (verbAnswer, bool) { if handActConn != nil || servingBus.Load() != nil { read = listHandActs } - case "queue": - if servingBus.Load() != nil { - read = listQueue - } } if read == nil { return verbAnswer{}, false @@ -1692,7 +1641,7 @@ func subIn(rest []string, subs ...string) bool { var commandReadForms = map[string]func(rest []string) bool{ "status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly, "hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly, - "artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly, + "artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, // The switch's list reads the builder's keys and the builds held, and moves nothing (issue 1851, A13). "switch": flagsOnly, // `plan ` previews a node's declaration; it sends nothing. diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index b41492dc..37193269 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -10,15 +10,11 @@ import ( "testing" ) -// `builds` given a build's id reads that build's log from the bus rather than listing builds -// (novox/hq ADR 0157). -func TestBuildsWithAnIdReadsThatBuildsLog(t *testing.T) { - argv, err := argvFor("builds", map[string]any{"log": "build-17"}) - if err != nil { - t.Fatal(err) - } - if strings.Join(argv, " ") != "builds --log build-17" { - t.Fatalf("builds with a log id became %q", strings.Join(argv, " ")) +// `builds` given a build's id no longer reads a log from the bus: the build seat is retired (ADR 0525 clause 7). +func TestBuildsWithAnIdIsRefusedForTheRetiredSeat(t *testing.T) { + if _, err := argvFor("builds", map[string]any{"log": "build-17"}); err == nil || + !strings.Contains(err.Error(), "build seat is retired") { + t.Fatalf("builds with a log id: %v", err) } } @@ -125,7 +121,13 @@ func TestTheRemovedVerbsAreRefusedByName(t *testing.T) { {"build", map[string]any{"repository": "novox/x", "path": "modules/x"}, "`build` is removed"}, {"build", map[string]any{}, "`build` is removed"}, {"rebuild", map[string]any{"what": "gitea"}, "`rebuild` is removed"}, - {"replay", map[string]any{"id": "build-1", "register": "true", "older": "true"}, "`replay --register` is removed"}, + {"replay", map[string]any{"id": "build-1"}, "`replay` is removed"}, + {"queue", map[string]any{}, "`queue` is removed"}, + {"cancel", map[string]any{"id": "build-1"}, "`cancel` is removed"}, + {"clear", map[string]any{"dead": "true"}, "`clear` is removed"}, + {"kill", map[string]any{"id": "build-1"}, "`kill` is removed"}, + {"pause", map[string]any{"node": "ace"}, "`pause` is removed"}, + {"resume", map[string]any{}, "`resume` is removed"}, // 1318: delivery-plan and forget-checks go too. {"delivery-plan", map[string]any{"repository": "novox/a", "paths": "x"}, "`delivery-plan` is removed"}, {"forget-checks", map[string]any{"owner": "novox", "repo": "a", "number": "7"}, "`forget-checks` is removed"}, @@ -135,10 +137,7 @@ func TestTheRemovedVerbsAreRefusedByName(t *testing.T) { t.Errorf("%s %v: %v %v, want refused saying %q", c.verb, c.args, argv, err, c.said) } } - if argv, err := argvFor("replay", map[string]any{"id": "build-1"}); err != nil || strings.Join(argv, " ") != "replay build-1" { - t.Errorf("replay as a dry run: %v %v", argv, err) - } - for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery-plan"} { + for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery-plan", "queue", "replay"} { err := removedByName(removed) if err == nil || !strings.Contains(err.Error(), "`"+removed+"` is removed") || !strings.Contains(err.Error(), "ADR 05") { t.Errorf("%s: %v", removed, err) @@ -148,10 +147,6 @@ func TestTheRemovedVerbsAreRefusedByName(t *testing.T) { !strings.Contains(err.Error(), "`delivery-plan` is removed") { t.Errorf("delivery plan: %v", err) } - err := replayCommand(context.Background(), []string{"build-1", "--register"}) - if err == nil || !strings.Contains(err.Error(), "`replay --register` is removed") { - t.Errorf("replay --register: %v", err) - } } // What `tools` answers is the seats' records, with each verb's schema. @@ -232,7 +227,7 @@ func TestTheCommandVerbOnlyReads(t *testing.T) { } for _, line := range []string{ "status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection", - "images", "artifacts --collected", "data --machine a", "builds --log b", "queue", "plan ace --diff", + "images", "artifacts --collected", "data --machine a", "builds", "plan ace --diff", "plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list", "conditions show c", "conditions history", "node list", "node show ace", "module list", "settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r", diff --git a/cmd/mesh-controller/signals.go b/cmd/mesh-controller/signals.go index 76e65509..7ee34b89 100644 --- a/cmd/mesh-controller/signals.go +++ b/cmd/mesh-controller/signals.go @@ -62,10 +62,6 @@ const ( tierAtLeast = planWaitBound // loopDeafAfter is how long the event loop may take nothing while its consumers hold some (S4). loopDeafAfter = 2 * time.Minute - // askAtLeast is the least a build ask is given, and askDefault the bound while nothing is - // measured (S6): the build seat declares no timeout of its own. - askAtLeast = 20 * time.Minute - askDefault = time.Hour // callDefault is the bound of a verb that declares none (S7); push's and build's are longer. callDefault = 10 * time.Minute // advisoryQuiet is how long the bus must be quiet about a thing before its advisory clears (S9). @@ -114,9 +110,8 @@ var signalsTable = []signalRow{ return newestOf(f.machines, func(m machineFacts) time.Time { return m.reportedAt }) }}, {Row: "S3", Signal: "plan tier progress", Emitter: "controller's plan", Trigger: "each tier entered", - Bound: "max(30 min, 3 × the p90 of that repository's measured tiers), provisional; not while the " + - "build seat is paused under it", - Kind: "stalled", Severity: conditions.Warning, Phase: 1, + Bound: "max(30 min, 3 × the p90 of that repository's measured tiers), provisional", + Kind: "stalled", Severity: conditions.Warning, Phase: 1, needs: func(f *signalFacts) error { return f.plansErr }, watch: watchPlans, newest: func(f *signalFacts) time.Time { return newestOf(f.plans, func(p planFacts) time.Time { return p.entered }) @@ -126,12 +121,6 @@ var signalsTable = []signalRow{ Kind: "controller-deaf", Severity: conditions.Urgent, Phase: 1, needs: func(f *signalFacts) error { return f.loopErr }, watch: watchLoop, newest: func(f *signalFacts) time.Time { return f.loop.took }}, - {Row: "S6", Signal: "a build asked → its outcome", Emitter: "build seat", Trigger: "each ask", - Bound: "max(20 min, 3 × the p90 of measured builds), 1 h while nothing is measured, provisional — the " + - "build seat declares no timeout; and an ask the queue gave up on (dead) at once", - Kind: "ask-lost", Severity: conditions.Warning, Phase: 1, - needs: func(f *signalFacts) error { return f.asksErr }, watch: watchAsks, - newest: func(f *signalFacts) time.Time { return newestOf(f.asks, func(a askFacts) time.Time { return a.since }) }}, {Row: "S7", Signal: "a call running → finished", Emitter: "controller", Trigger: "each call", Bound: "the verb's bound: push, rotate and command 30 min, assign and unassign 15 min, doctor 3 min, " + "any other 10 min", @@ -407,9 +396,9 @@ func watchReports(f *signalFacts) []conditions.Observation { func watchPlans(f *signalFacts) []conditions.Observation { var out []conditions.Observation for _, p := range f.plans { - // Under a paused build seat, or held only by the bus's planned step (S17, novox/hq issue 336): a wait + // Held only by the bus's planned step (S17, novox/hq issue 336): a wait // for a person, said as itself, not a walk running late. - if p.paused || p.bus { + if p.bus { continue } in := f.now.Sub(p.entered) @@ -521,32 +510,6 @@ func watchLoop(f *signalFacts) []conditions.Observation { Said: fmt.Sprintf("%d pending (%s), last taken %s", f.loop.pending, f.loop.where, since.UTC().Format(time.RFC3339))}} } -func watchAsks(f *signalFacts) []conditions.Observation { - var out []conditions.Observation - for _, a := range f.asks { - var said string - switch { - case a.state == link.AskDead: - said = fmt.Sprintf("the %s queue handed it out as often as it may and it was never settled", a.seat) - case a.state == link.AskInFlight && f.now.Sub(a.since) > a.bound: - said = fmt.Sprintf("in flight on %s for %s (bound %s)", orSomewhere(a.on), ago(f.now.Sub(a.since)), ago(a.bound)) - default: - continue - } - out = append(out, conditions.Observation{Scope: conditions.ScopeBuild, ID: a.id, Kind: "ask-lost", - Token: "lost", Machine: a.on, Severity: conditions.Warning, - Summary: fmt.Sprintf("the build %s of %s has no outcome: %s", a.id, a.what, said), Said: said}) - } - return out -} - -func orSomewhere(node string) string { - if node == "" { - return "a machine that did not say which" - } - return node -} - func watchCalls(f *signalFacts) []conditions.Observation { var out []conditions.Observation for _, c := range f.calls { @@ -738,8 +701,6 @@ func consumerWho(stream, consumer string) string { switch { case consumer == broker.ControllerName || consumer == broker.MergesConsumerName: return "the controller" - case broker.IsCheckStream(stream): - return "the holder of " + broker.TheBuildSeat case strings.HasPrefix(stream, "SEAT_") && strings.HasSuffix(consumer, "_worker"): seat := strings.ToLower(strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(consumer, "SEAT_"), "_worker"), "_", "-")) return "the holder of " + seat diff --git a/cmd/mesh-controller/signals_test.go b/cmd/mesh-controller/signals_test.go index 9bf41d09..bbb0f09a 100644 --- a/cmd/mesh-controller/signals_test.go +++ b/cmd/mesh-controller/signals_test.go @@ -71,16 +71,6 @@ var suppressions = map[string]suppression{ inside: func(f *signalFacts) { f.loop.took = f.now.Add(-119 * time.Second) }, past: func(f *signalFacts) { f.loop.took = f.now.Add(-121 * time.Second) }, }, - "S6": { - inside: func(f *signalFacts) { - f.asks = []askFacts{{id: "build-1", seat: "node-build-agent", what: "novox/app", state: link.AskInFlight, - on: "anchor", since: f.now.Add(-59 * time.Minute), bound: time.Hour}} - }, - past: func(f *signalFacts) { - f.asks = []askFacts{{id: "build-1", seat: "node-build-agent", what: "novox/app", state: link.AskInFlight, - on: "anchor", since: f.now.Add(-61 * time.Minute), bound: time.Hour}} - }, - }, "S7": { inside: func(f *signalFacts) { f.calls = []link.Call{{ID: "call-1", Seat: "mesh-controller", Verb: "push", Started: f.now.Add(-29 * time.Minute)}} diff --git a/cmd/mesh-controller/silent_mid_tier_integration_test.go b/cmd/mesh-controller/silent_mid_tier_integration_test.go index dac3e72b..2593d8b1 100644 --- a/cmd/mesh-controller/silent_mid_tier_integration_test.go +++ b/cmd/mesh-controller/silent_mid_tier_integration_test.go @@ -63,18 +63,8 @@ func TestAPlanPassesATierWhoseMachineNeverReportsAfterTheBoundAndSendsTheNextToT // The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept. var mu sync.Mutex - var asks []string var sends [][]string - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - wasAsk := askABuild - askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) { - mu.Lock() - defer mu.Unlock() - id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond)) - asks = append(asks, source.Repository+"#"+id) - return id, nil - } - t.Cleanup(func() { askABuild = wasAsk }) + asksRecorded(t) // each module is taken from a done key as the walk reaches it digests := 0 wasSend := sendRollout sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) { @@ -200,13 +190,7 @@ func TestAPlanPassesATierWhoseMachineNeverReportsAfterTheBoundAndSendsTheNextToT if !strings.Contains(moved, "laptop") || strings.Contains(moved, "anchor") { t.Errorf("1160/AC5: the plan says %q; want laptop named silent and not anchor", moved) } - // The ask for tier 1's build is the next step, and records its build id on the module. - eventually(t, "tier 1 asked", func() bool { m := plan().Modules["b"]; return m != nil && m.Build != "" }) - b := plan().Modules["b"] - askedAt, _ := link.BuildAskedAt(b.Build) - record(b.Build, "b", "c2", askedAt) - register("b", "c2", askedAt) - planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build) + // Tier 1's module is taken from its key, which the walk's look does by itself. eventually(t, "tier 1 sent to anchor alone", func() bool { mu.Lock() defer mu.Unlock() diff --git a/cmd/mesh-controller/source.go b/cmd/mesh-controller/source.go index b54a6678..29dfb760 100644 --- a/cmd/mesh-controller/source.go +++ b/cmd/mesh-controller/source.go @@ -1,9 +1,7 @@ package main import ( - "context" "fmt" - "os" "strconv" "strings" @@ -50,47 +48,6 @@ func onASeat(repository string) error { return nil } -// cloneFrom is the URL a build machine clones for a source. -// -// A URL is itself. A path on a seat is composed from the seat's holder as the mesh sees it now — -// the same view planning takes of every machine, so the forge a build clones from is the forge the -// mesh says holds the seat. -func cloneFrom(ctx context.Context, source buildSource) (string, error) { - if source.Seat == "" { - return source.Repository, nil - } - open, err := openStores(ctx) - if err != nil { - return "", err - } - defer open.Close() - shelf, err := open.inventory.Catalogue(ctx) - if err != nil { - return "", err - } - world, err := restOfTheMesh(ctx, open, shelf, "") - if err != nil { - return "", err - } - return clonedFromSeat(world, source.Seat, source.Repository) -} - -// clonedFromSeat composes the clone URL for a repository on a seat's holder. -// -// **Refused, never defaulted, at every step that has no answer.** Nobody holding the seat is a mesh -// without a forge of its own: it builds from external repositories and must say so rather than fail -// to clone. A holder off the private network cannot be reached by any build machine. A holder that -// serves no scheme or port has nothing to compose from — a default port here would be the forge's -// address guessed, which is the thing this exists to stop. -func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) { - base, err := seatBase(world, seatName) - if err != nil { - return "", err - } - path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git") - return fmt.Sprintf("%s/%s.git", base, path), nil -} - // seatBase is `scheme://host:port` of a seat's holder as the mesh reaches it, for cloning. func seatBase(world catalogue.World, seatName string) (string, error) { seat, known := catalogue.SeatNamed(seatName) @@ -132,41 +89,6 @@ func seatBase(world catalogue.World, seatName string) (string, error) { return fmt.Sprintf("%s://%s:%s", scheme, provider.At, port), nil } -// seatBases is the clone base of every seat a recipe's context may name, for a build request -// (novox/hq ADR 0155). A seat nobody holds is left out rather than refused here: the build may not -// name it at all, and if it does the builder refuses with the seat's name. -// -// **What cannot be read is said, not passed over as no seats** (novox/hq to-be 45 Phase 2, the -// empty-on-error lint): the build still goes ahead — one that names no seat needs none — and one that -// does is refused naming it, but the reason is the store, and that is said here where it is known. -func seatBases(ctx context.Context) map[string]string { - unread := func(what string, err error) map[string]string { - fmt.Fprintf(os.Stderr, "could not read %s, so a build naming a seat's clone base will be told that "+ - "seat is not held: %v\n", what, err) - return nil - } - open, err := openStores(ctx) - if err != nil { - return unread("the mesh's store", err) - } - defer open.Close() - shelf, err := open.inventory.Catalogue(ctx) - if err != nil { - return unread("the catalogue", err) - } - world, err := restOfTheMesh(ctx, open, shelf, "") - if err != nil { - return unread("who holds which seat", err) - } - bases := map[string]string{} - for _, seatName := range []string{gitSeat} { - if base, err := seatBase(world, seatName); err == nil { - bases[seatName] = base - } - } - return bases -} - // servedPort is a served port as text, however the manifest and the node's settings carried it. func servedPort(v any) string { switch p := v.(type) { diff --git a/cmd/mesh-controller/source_test.go b/cmd/mesh-controller/source_test.go index ab9c8ccd..eab53021 100644 --- a/cmd/mesh-controller/source_test.go +++ b/cmd/mesh-controller/source_test.go @@ -1,7 +1,6 @@ package main import ( - "strings" "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -22,68 +21,6 @@ func forgeHolding(port any) catalogue.World { } } -func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) { - got, err := clonedFromSeat(forgeHolding(float64(3000)), "git", "novox/mesh-catalog") - if err != nil { - t.Fatal(err) - } - if got != "http://anchor.internal:3000/novox/mesh-catalog.git" { - t.Fatalf("cloned from %s", got) - } -} - -func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) { - // The whole point: the node gave the forge another port, and the same recorded path clones - // from the new one. Nothing recorded contained the old one to be wrong. - got, err := clonedFromSeat(forgeHolding(float64(3100)), "git", "novox/mesh-catalog") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(got, ":3100/") { - t.Fatalf("the moved port was not followed: %s", got) - } -} - -func TestWithNobodyHoldingTheSeatASelfHostedBuildIsRefusedAndSaysWhy(t *testing.T) { - _, err := clonedFromSeat(catalogue.World{}, "git", "novox/mesh-catalog") - if err == nil { - t.Fatal("a repository was cloned from a forge the mesh does not have") - } - for _, want := range []string{"nobody holds the git seat", "without --self"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("the refusal does not say %q: %v", want, err) - } - } -} - -func TestAnExternalRepositoryIsClonedExactlyAsGiven(t *testing.T) { - // Unaffected by the seat, held or not: GitHub and GitLab are the ordinary cases. - given := "https://github.com/someone/something.git" - got, err := cloneFrom(t.Context(), buildSource{Repository: given}) - if err != nil { - t.Fatal(err) - } - if got != given { - t.Fatalf("an external repository became %s", got) - } -} - -func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) { - world := forgeHolding(float64(3000)) - world.Offered["git"][1].At = "" - if _, err := clonedFromSeat(world, "git", "novox/mesh-catalog"); err == nil || - !strings.Contains(err.Error(), "private network") { - t.Fatalf("a forge nothing can reach was cloned from: %v", err) - } -} - -func TestAHolderServingNoPortIsRefusedRatherThanGuessed(t *testing.T) { - // A default port would be the forge's address guessed, which is what this exists to stop. - if _, err := clonedFromSeat(forgeHolding(nil), "git", "novox/mesh-catalog"); err == nil { - t.Fatal("a port was guessed for a forge that serves none") - } -} - func TestAnAddressGivenAsAPathOnTheForgeIsRefused(t *testing.T) { for _, bad := range []string{ "https://github.com/someone/something.git", diff --git a/cmd/mesh-controller/states_announced_integration_test.go b/cmd/mesh-controller/states_announced_integration_test.go index 0e64ca7f..32d9beab 100644 --- a/cmd/mesh-controller/states_announced_integration_test.go +++ b/cmd/mesh-controller/states_announced_integration_test.go @@ -77,7 +77,7 @@ func TestTheStatesAnnouncedGrantTheReads(t *testing.T) { } becomes(t, 10*time.Second, "both words kept", func() bool { a, err := readAnnouncements(ctx) - return err == nil && len(a) == 2 && a["owner"].Readable("open") && a[broker.ControllerSeat].Readable(broker.PlansBuilding) + return err == nil && len(a) == 2 && a["owner"].Readable("open") && a[broker.ControllerSeat].Readable(broker.ChainBuilds) }) records, err := inv.BusRecords(ctx) diff --git a/cmd/mesh-controller/status.go b/cmd/mesh-controller/status.go index 93191908..32c9ac25 100644 --- a/cmd/mesh-controller/status.go +++ b/cmd/mesh-controller/status.go @@ -152,14 +152,14 @@ func printStatus(asked answers) error { printPending(asked.pending, asked.pendingUnread) - if open, late := openPlans(asked.plans, time.Now(), asked.paused, asked.tierBounds); len(open) > 0 { + if open, late := openPlans(asked.plans, time.Now(), asked.tierBounds); len(open) > 0 { fmt.Printf("%d plan(s) open", len(open)) if late > 0 { fmt.Printf(", %d in a tier past its bound", late) } fmt.Println(":") for _, p := range open { - fmt.Printf(" %s\n", planLineWith(p, time.Now(), asked.paused, asked.tierBounds.of(p.Repository))) + fmt.Printf(" %s\n", planLineWith(p, time.Now(), asked.tierBounds.of(p.Repository))) } fmt.Println() } @@ -393,16 +393,6 @@ func firstLine(s string) string { return strings.TrimSpace(s) } -// builds keeps what a builder said, for the serving control plane. -// -// A type of its own rather than a method on the enrolment, because they are unrelated things -// arriving on one queue and an implementation of one should not have to say anything about the -// other. -type builds struct { - inv *inventory.Inventory - open *stores -} - // theThreeQuestions reads what anything answering "is the mesh alright" needs. // // **One reading, for every way of saying it** (novox/hq 03-DESIGN/01-to-be/11-a-board.md). There @@ -511,7 +501,6 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) { return answers{}, err } // Whether the build seat takes work, for a plan waiting on it (novox/hq ADR 0219). - out.paused = buildSeatPause(ctx, inv, out.plans) // And how long a tier may take, the bound a plan is late at (novox/hq issue 296). out.tierBounds = readTierBounds(ctx, inv, time.Now()) // And how many repairs were done by hand this week (novox/hq to-be 45 §7) — where there is a bus diff --git a/cmd/mesh-controller/switch_list.go b/cmd/mesh-controller/switch_list.go index b3ac0ac5..a34a277e 100644 --- a/cmd/mesh-controller/switch_list.go +++ b/cmd/mesh-controller/switch_list.go @@ -160,7 +160,7 @@ func switchList(ctx context.Context, inv *inventory.Inventory, keys switchReads) var out []switchLine for _, e := range entries { m := e.Manifest.Module - if e.Provided || e.Source.Repository == "" || isBuildersOwn(m) { + if e.Provided || e.Source.Repository == "" { continue } trunk, err := keys.ModuleAt(ctx, m, "") diff --git a/cmd/mesh-controller/take_refused.go b/cmd/mesh-controller/take_refused.go deleted file mode 100644 index 2d31edab..00000000 --- a/cmd/mesh-controller/take_refused.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "context" - "fmt" - "time" - - "github.com/novox/mesh-controller/internal/link" -) - -// refusalKeeper keeps a refused registration in the store (*inventory.Inventory, or a test's). -type refusalKeeper interface { - RecordRefused(ctx context.Context, id, module, why string) error -} - -// refusedOutcome is how a refused build reaches the plans: built takes it in by its module (planBuilt), failedBuild -// by the id the plan asked it under (planFailedBuild). -type refusedOutcome struct { - built func(module, why string) - failedBuild func(result link.BuildResult) -} - -// takeRefused takes in a build heard and recorded, and not registered (novox/hq issue 1713). -// -// **The refusal is kept by the build's id, whatever the plans' state**: in the store (RecordRefused) and in this -// process (keepRefusalFor). Taking it into the plans keeps it only when a plan is open or the plans are held, and a -// tier's first failure fails its plan, which no outcome reaches after that: the build record says the builder's words, -// which worked, and a Retry settled from it sent the module's old version and reported the plan done (on 2026-10-23 -// 12:01, plan-1792748906937927069 sent mesh-build-go, mesh-build-node, mosquitto and ssh-client so). -// -// **A build whose module is not known fails the plan that asked it by its id** (planFailedBuild, moduleAskedAs), as -// a failed build that names no module does: a manifest that could not be read is no reason for the plan not to hear. -func takeRefused(ctx context.Context, keeper refusalKeeper, result link.BuildResult, module, why string, - plans refusedOutcome) { - if result.ID != "" { - if err := keeper.RecordRefused(ctx, result.ID, module, why); err != nil { - fmt.Printf("%s: the refusal could not be kept in the store, only in this controller: %v\n", result.ID, err) - } - keepRefusalFor(result.ID, why, time.Now()) - } - if module != "" { - plans.built(module, why) - return - } - failed := result - failed.Failed = why - plans.failedBuild(failed) -} diff --git a/cmd/mesh-controller/take_refused_test.go b/cmd/mesh-controller/take_refused_test.go deleted file mode 100644 index 8e7cc6b0..00000000 --- a/cmd/mesh-controller/take_refused_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package main - -import ( - "context" - "reflect" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// keptRefusals is a store that keeps refusals, by id. -type keptRefusals map[string]string - -func (k keptRefusals) RecordRefused(_ context.Context, id, _, why string) error { - if _, ok := k[id]; !ok { - k[id] = why - } - return nil -} - -// failedPlanOf is a plan already failed on gitea, its tier also asking module under id: open() does not count it, so -// no outcome taken in reaches it (as plan-1792748906937927069 on 2026-10-23 11:51). -func failedPlanOf(module, id string, asked time.Time) inventory.Plan { - return inventory.Plan{ID: "plan-1713", State: inventory.PlanFailed, Tier: 0, - Tiers: [][]string{{"gitea", module}}, Note: "gitea failed to build in tier 0", - Modules: map[string]*inventory.PlanModule{ - "gitea": {State: "failed", AskedAt: &asked, Build: "build-1713-gitea", Why: "not registered"}, - module: {State: "asked", AskedAt: &asked, Build: id}, - }} -} - -// retried is what a Retry of the plan asks again, its records read as recordsOfAsked reads them: the build record of -// id (which says the builder's words, worked) with every refusal kept for it in the store and in this process. -func retried(p *inventory.Plan, store keptRefusals, id, module string, at time.Time) []string { - byID := map[string]inventory.Build{id: {ID: id, Module: module, Commit: "6c0024e4", At: at}} - refused := map[string]string{} - for k, why := range store { - refused[k] = why - } - for k, why := range refusalsKeptFor([]string{id}) { - refused[k] = why - } - overlayRefusals(nil, byID, refused) - return toRetry(p, nil, byID) -} - -// 1713/AC1: given a build of a module heard and recorded and not registered while its plan is failed and the plans are -// not held, when the controller takes it in, then the refusal is kept by id, and a Retry asks the module again instead -// of settling it built and sending its old version (plan-1792748906937927069 sent mosquitto's on 2026-10-23 12:01). -func TestARefusalWhileThePlanIsFailedIsKeptAndRetried(t *testing.T) { - asked := time.Date(2026, 10, 23, 11, 51, 0, 0, time.UTC) - id := "build-1713-ac1-mosquitto" - p := failedPlanOf("mosquitto", id, asked) - store := keptRefusals{} - var took []string - takeRefused(t.Context(), store, link.BuildResult{ID: id, Commit: "6c0024e4"}, "mosquitto", - "the trunk requires no status", refusedOutcome{ - built: func(module, why string) { took = append(took, module+": "+why) }, - failedBuild: func(link.BuildResult) { t.Fatal("a refusal with its module was taken by id") }, - }) - - if !reflect.DeepEqual(took, []string{"mosquitto: the trunk requires no status"}) { - t.Fatalf("the plans were told %v", took) - } - if store[id] == "" { - t.Fatal("the refusal was not kept in the store") - } - if got := retried(&p, keptRefusals{}, id, "mosquitto", asked.Add(time.Minute)); !reflect.DeepEqual(got, - []string{"gitea", "mosquitto"}) { - t.Fatalf("a Retry, the refusal kept only in this controller, asks %v; mosquitto was settled %q", - got, p.Modules["mosquitto"].State) - } -} - -// 1713/AC2: given a build that came back without a readable manifest and was not registered, when the controller takes -// it in, then the asking plan is failed by the build's id and the refusal is kept by id, so a Retry asks it again. -func TestARefusalWithoutAModuleFailsItsPlanByIDAndIsRetried(t *testing.T) { - asked := time.Date(2026, 10, 23, 11, 51, 0, 0, time.UTC) - id := "build-1713-ac2-ssh-client" - p := failedPlanOf("ssh-client", id, asked) - store := keptRefusals{} - var failed []link.BuildResult - takeRefused(t.Context(), store, link.BuildResult{ID: id, Commit: "6c0024e4"}, "", - "what came back is not a manifest", refusedOutcome{ - built: func(module, _ string) { t.Fatalf("a refusal without a module was taken as %q", module) }, - failedBuild: func(r link.BuildResult) { failed = append(failed, r) }, - }) - - if len(failed) != 1 || failed[0].ID != id || !strings.Contains(failed[0].Failed, "not a manifest") { - t.Fatalf("the asking plan was not failed by the build's id: %+v", failed) - } - if store[id] == "" { - t.Fatal("the refusal was not kept in the store") - } - if got := retried(&p, store, id, "", asked.Add(time.Minute)); !reflect.DeepEqual(got, - []string{"gitea", "ssh-client"}) { - t.Fatalf("a Retry asks %v; ssh-client was settled %q", got, p.Modules["ssh-client"].State) - } -} - -// 1713/AC3: given a refused build kept by id, when the records settle a building plan's tier, then the module and the -// plan are failed, never built: the plan never reports done for it. -func TestAPlanNeverSettlesARefusedBuildBuilt(t *testing.T) { - asked := time.Date(2026, 10, 23, 11, 51, 0, 0, time.UTC) - id := "build-1713-ac3-mesh-build-go" - takeRefused(t.Context(), keptRefusals{}, link.BuildResult{ID: id}, "mesh-build-go", "not registered", - refusedOutcome{built: func(string, string) {}, failedBuild: func(link.BuildResult) {}}) - p := inventory.Plan{ID: "plan-1713-ac3", State: inventory.PlanBuilding, Tiers: [][]string{{"mesh-build-go"}}, - Modules: map[string]*inventory.PlanModule{"mesh-build-go": {State: "asked", AskedAt: &asked, Build: id}}} - byID := map[string]inventory.Build{id: {ID: id, Module: "mesh-build-go", At: asked.Add(time.Minute)}} - overlayRefusals(nil, byID, refusalsKeptFor([]string{id})) - settleFromRecords(&p, p.Tiers[0], nil, byID) - if s := p.Modules["mesh-build-go"]; s.State != "failed" || p.State != inventory.PlanFailed { - t.Fatalf("a refused build settled as %q, the plan %q", s.State, p.State) - } -} diff --git a/cmd/mesh-controller/template_integration_test.go b/cmd/mesh-controller/template_integration_test.go deleted file mode 100644 index 8b847819..00000000 --- a/cmd/mesh-controller/template_integration_test.go +++ /dev/null @@ -1,148 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - snapshot "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/inventory" -) - -func templatedMesh(t *testing.T) (snapshot.Facts, map[string]string) { - t.Helper() - open := aMesh(t) - ctx := t.Context() - manifests := map[string]string{ - "lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"], - "resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`, - "notifier": inlineNotifier, - } - for name, raw := range manifests { - m, err := catalogue.ParseManifest([]byte(raw)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", - Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil { - t.Fatal(err) - } - if _, err := assign(ctx, open, "laptop", name); err != nil { - t.Fatalf("assign %s: %v", name, err) - } - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - return f, manifests -} - -func TestTheMergeGateJudgesAChangeToATemplateAlone(t *testing.T) { - f, manifests := templatedMesh(t) - - // The module moved to its template, the same text: nothing changes on any machine. - v := gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 3\n", true), "modules/notifier/module.json", - notifierTemplate) - if v.Verdict == "fail" || v.Modules["notifier"] != "" { - t.Fatalf("the module moved to its template with the same text reads as a change (%v):\n%s", v.Modules, v.Report()) - } - - // The template alone changed: its module changed, and composes. - v = gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 4\n", true), notifierTemplate) - if v.Verdict == "fail" || v.Modules["notifier"] != "changed" { - t.Fatalf("a change to the template alone is not a change to its module (%v):\n%s", v.Modules, v.Report()) - } - if v.Width == nil || strings.Join(v.Width.Modules, ",") != "notifier" { - t.Fatalf("a change to the template rebuilds %+v, not its module", v.Width) - } - - // The template alone changed to text the controller refuses: the pull request fails, naming the module. - v = gateJudged(t, f, aTemplatedTree(t, manifests, "store = ${dir:nowhere}\n", true), notifierTemplate) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "notifier") { - t.Fatalf("a template the controller refuses passed the gate:\n%s", v.Report()) - } - - // A symlinked files/ fails: nothing is read from outside the tree. - tree := aTree(t, withEdit(manifests, "notifier", "")) - elsewhere := t.TempDir() - if err := os.WriteFile(filepath.Join(elsewhere, "notifier.conf"), []byte("volume = 3\n"), 0o644); err != nil { - t.Fatal(err) - } - aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, nil) - if err := os.Symlink(elsewhere, filepath.Join(tree, "modules", "notifier", "files")); err != nil { - t.Fatal(err) - } - v = gateJudged(t, f, tree, notifierTemplate) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/ is a symlink") { - t.Fatalf("a symlinked files/ passed the gate:\n%s", v.Report()) - } - - // And the template removed from the tree fails, naming it. - v = gateJudged(t, f, aTemplatedTree(t, manifests, "", false), notifierTemplate) - if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/notifier.conf") { - t.Fatalf("a missing template passed the gate:\n%s", v.Report()) - } -} - -func TestTheMergeGateResolvesATemplatedModuleToItsInlineTwin(t *testing.T) { - dir := t.TempDir() - path := aTemplatedModule(t, dir, templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"}) - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - m, err := catalogue.ParseManifest(raw) - if err != nil { - t.Fatal(err) - } - texts, err := catalogue.ReadTemplates(m, dir) - if err != nil { - t.Fatal(err) - } - resolved, err := standInBuild(m, texts) - if err != nil { - t.Fatal(err) - } - inline, err := catalogue.ParseManifest([]byte(inlineNotifier)) - if err != nil { - t.Fatal(err) - } - if builtAlike(resolved) != builtAlike(inline) { - got, _ := json.Marshal(resolved) - t.Fatalf("the templated module resolves to %s, not its inline twin", got) - } -} - -func TestReplay876(t *testing.T) { - moved := strings.Replace(templatedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1) - judge := func(manifest string, files map[string]string) (map[string]catalogue.Manifest, []string) { - tree := t.TempDir() - aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), manifest, files) - out, failures, err := shelfWithChange(map[string]catalogue.Manifest{}, nil, snapshot.Facts{}, - "novox/mesh-catalog", tree, map[string]string{}, nil) - if err != nil { - t.Fatal(err) - } - return out, failures - } - out, failures := judge(moved, map[string]string{"files/notifier.conf": "volume = 3\n"}) - if len(failures) > 0 { - t.Fatalf("a moved module naming its templates was refused: %v", failures) - } - if h := out["notifier"].Handed; h == nil || h.Templates["conf"] != "notifier.conf" { - t.Fatalf("the gate did not hand the moved module its templates: %+v", h) - } - - // A moved module naming no template is still refused. - none := strings.Replace(strings.Replace(moved, `"template":"files/notifier.conf"`, - `"content":"volume = 3\n"`, 1), `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) - if _, failures := judge(none, nil); len(failures) != 1 || !strings.Contains(failures[0], "names no template") { - t.Fatalf("a moved module naming no template was not refused: %v", failures) - } -} diff --git a/cmd/mesh-controller/template_test.go b/cmd/mesh-controller/template_test.go index 2ece11f5..d3972043 100644 --- a/cmd/mesh-controller/template_test.go +++ b/cmd/mesh-controller/template_test.go @@ -79,20 +79,6 @@ func TestModuleCheckReadsTemplatesFromTheTreeBesideTheManifest(t *testing.T) { // templatedMesh is catalogueMesh with the notifier on the laptop, registered as built: its text inline. -// aTemplatedTree is the catalogue's checkout with the notifier's text in files/. -func aTemplatedTree(t *testing.T, manifests map[string]string, text string, has bool) string { - t.Helper() - tree := aTree(t, withEdit(manifests, "notifier", "")) - files := map[string]string{} - if has { - files["files/notifier.conf"] = text - } - aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, files) - return tree -} - -const notifierTemplate = "modules/notifier/files/notifier.conf" - // What the gate composes for the module in its template form is what the mesh composes for it inline. // A module whose files/ is unpacked onto the machine names its own templates directory (issue 731): `module check` diff --git a/cmd/mesh-controller/tier_send_integration_test.go b/cmd/mesh-controller/tier_send_integration_test.go index b933516a..5ab4a20f 100644 --- a/cmd/mesh-controller/tier_send_integration_test.go +++ b/cmd/mesh-controller/tier_send_integration_test.go @@ -302,10 +302,7 @@ func TestAModuleItsSendChangedNothingOfIsLeftAsItWasAndRetried(t *testing.T) { } } - asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it - was := askABuild - askABuild = func(context.Context, buildSource, string, string) (string, error) { return "build-3", nil } - t.Cleanup(func() { askABuild = was }) + everyKeyDone(t) // the retried module is taken from a done key g.health["anchor"] = healthGood said, err := retryPlan(ctx, g.open, "plan-gate") if err != nil { @@ -313,7 +310,7 @@ func TestAModuleItsSendChangedNothingOfIsLeftAsItWasAndRetried(t *testing.T) { } p = g.plan(t) s := p.Modules["app"] - if p.State != inventory.PlanBuilding || s.State != "asked" || s.Build != "build-3" || s.FirstAt != nil || s.Gate != nil { + if p.State != inventory.PlanBuilding || s.State != "asked" || !s.Key || s.FirstAt != nil || s.Gate != nil { t.Fatalf("retried as %q: the plan is %s, app %+v", said, p.State, s) } } diff --git a/cmd/mesh-controller/try.go b/cmd/mesh-controller/try.go index 7dcd4ed3..b7ed722a 100644 --- a/cmd/mesh-controller/try.go +++ b/cmd/mesh-controller/try.go @@ -63,13 +63,6 @@ type tryAnswer struct { Last string `json:"last,omitempty"` } -// isTry says a check's outcome is a try's: marked so, or — echoed by a build seat from before the mark — a -// check of no pull request and no group, which is nobody's status either. -func isTry(result link.BuildResult) bool { - c := result.Checked - return c != nil && (c.Try || (c.Number == 0 && c.Group == "")) -} - // checkerModule is the module whose tools judge a try (mesh-delivery-checker). const checkerModule = "mesh-delivery-checker" diff --git a/cmd/mesh-controller/try_integration_test.go b/cmd/mesh-controller/try_integration_test.go index 5885470c..f3da3b01 100644 --- a/cmd/mesh-controller/try_integration_test.go +++ b/cmd/mesh-controller/try_integration_test.go @@ -11,38 +11,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" ) -func TestATrysOutcomeSetsNoStatusAndRegistersNothing(t *testing.T) { - said := &saidOnTheBus{} - checkEvents = said - defer func() { checkEvents = nil }() - for name, result := range map[string]link.BuildResult{ - "a try": {ID: "build-1", Ref: "c0ffee", Checked: &link.CheckRequest{Owner: "novox", Repo: "a", Try: true}, - Check: &link.CheckOutcome{Verdict: "pass", RepoCheck: &link.CheckLayer{Verdict: "fail", Summary: "failed"}}}, - "a try that could not run": {ID: "build-2", Checked: &link.CheckRequest{Owner: "novox", Repo: "a", Try: true}, - Failed: "cannot clone"}, - // A build seat from before the mark echoes the ask without it: a check of no pull request and no - // group is still nobody's status. - "a try echoed by an older build seat": {ID: "build-3", Checked: &link.CheckRequest{Owner: "novox", Repo: "a"}, - Check: &link.CheckOutcome{Verdict: "pass"}}, - } { - if err := (builds{}).Built(context.Background(), result); err != nil { - t.Fatalf("%s: %v", name, err) - } - } - if len(said.events) != 0 { - t.Fatalf("a try's outcome was said for the forge: %v", said.events) - } - // A pull request's check is still said. - _ = (builds{}).Built(context.Background(), link.BuildResult{ID: "build-4", Checked: &link.CheckRequest{ - Owner: "novox", Repo: "a", Number: 7}, Check: &link.CheckOutcome{Verdict: "pass"}}) - if len(said.events) != 1 { - t.Fatalf("a pull request's check was said %d time(s)", len(said.events)) - } -} - // ADR 0501 §5 (issue 1851): a try is asked of the checker, the head merged onto the trunk, never of build-agent: // --store-only is the integration level, --tests passes on, the ask answers the checker's id. func TestATryIsAskedOfTheChecker(t *testing.T) { diff --git a/cmd/mesh-controller/upgrades.go b/cmd/mesh-controller/upgrades.go index f2140362..d3155a4f 100644 --- a/cmd/mesh-controller/upgrades.go +++ b/cmd/mesh-controller/upgrades.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "github.com/novox/mesh-controller/internal/gosource" "regexp" "slices" "sort" @@ -13,7 +14,6 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -483,7 +483,7 @@ func whyMoved(e inventory.Entry, read []inventory.ReadRepository, m link.SourceM held := func(paths []string) []string { var in []string for _, p := range m.Paths { - if builder.SourceHolds(paths, p) { + if gosource.SourceHolds(paths, p) { in = append(in, p) } } @@ -670,7 +670,7 @@ func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool { return true } for _, p := range m.Paths { - if builder.SourceHolds(r.Paths, p) { + if gosource.SourceHolds(r.Paths, p) { return true } } @@ -695,7 +695,7 @@ func ownSource(read []inventory.ReadRepository) []string { // for a module built from the repository's root. func readsFile(e inventory.Entry, read []inventory.ReadRepository, p string) bool { if own := ownSource(read); own != nil { - return builder.SourceHolds(own, p) + return gosource.SourceHolds(own, p) } return readsDirectory(e, p) } diff --git a/cmd/mesh-controller/usage_test.go b/cmd/mesh-controller/usage_test.go index 034ac0b2..d6239e10 100644 --- a/cmd/mesh-controller/usage_test.go +++ b/cmd/mesh-controller/usage_test.go @@ -9,7 +9,8 @@ import ( // C3 (issue 1851 A11a, A11b, A13; ADR 0501 §4, §5): the terminal's help lists no command it refuses as removed, and lists // the ones R4e added: `register` and `switch`. func TestTheHelpListsNoRemovedCommandAndTheNewOnes(t *testing.T) { - for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery plan"} { + for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery plan", "queue", "cancel", "clear", "replay", + "kill", "pause", "builder issue"} { if regexp.MustCompile(`(?m)^ ` + regexp.QuoteMeta(removed) + ` `).MatchString(usageText) { t.Errorf("the help still lists %q, which is refused as removed", removed) } diff --git a/cmd/mesh-controller/walk_from_keys_integration_test.go b/cmd/mesh-controller/walk_from_keys_integration_test.go index bc8960c1..a63213ab 100644 --- a/cmd/mesh-controller/walk_from_keys_integration_test.go +++ b/cmd/mesh-controller/walk_from_keys_integration_test.go @@ -3,6 +3,7 @@ package main import ( + "github.com/nats-io/nats.go" "reflect" "strings" "testing" @@ -102,3 +103,84 @@ func TestAFailedKeyFailsTheWalkInTheBuildersWords(t *testing.T) { t.Fatalf("A7: a failed walk sent %v", tm.sent) } } + +// C2's criterion (issue 1851, R4g): a merge whose walk moves the builder, a build module and the controller takes every one +// from the builder's referenced done key, and no ask reaches any subject of the seat node-build-agent. A fake builder writes +// the keys on a real bus. +func TestTheBuilderABuildModuleAndTheControllerAreTakenFromKeysAndNothingIsAskedOfBuildAgent(t *testing.T) { + names := []string{sdkdelivery.BuilderModule, "mesh-build-go", "mesh-controller"} + tm := aTierMesh(t, names...) + ctx := t.Context() + inv := tm.open.inventory + b := aBuilderOnTheBus(t) + asked := make(chan string, 16) + sub, err := b.js.Conn().Subscribe("mesh.seat.node-build-agent.>", func(m *nats.Msg) { asked <- m.Subject }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + + p := tm.plan(t) + p.Commit = "c5c5c5c5c5c5c5c5" + for _, m := range names { + p.Modules[m] = &inventory.PlanModule{} + } + if err := inv.SavePlan(ctx, &p); err != nil { + t.Fatal(err) + } + for i, m := range names { + fp := fpOf(byte('a' + i)) + manifest := b.indexed(t, m, "c5", "novox/mesh-catalog", "modules/"+m, p.Commit, fp) + b.built(t, m, "c5", fp, manifest, sdkdelivery.BuildDone) + } + becomes(t, 10*time.Second, "the three taken from their keys", func() bool { + advancePlans(ctx, tm.open) + q := tm.plan(t) + for _, m := range names { + if s := q.Modules[m]; s == nil || s.State != "built" { + return false + } + } + return true + }) + p = tm.plan(t) + for _, m := range names { + if s := p.Modules[m]; !s.Key || !strings.HasPrefix(s.Build, "key-") || !strings.Contains(s.Made, "taken from the builder's key") { + t.Errorf("%s was not taken from the builder's referenced key: %+v", m, s) + } + } + select { + case subject := <-asked: + t.Fatalf("an ask reached the retired build seat: %s", subject) + case <-time.After(300 * time.Millisecond): + } +} + +// C5's criterion: after a plan that moves the builder, its record names the builder's key and its fingerprint. +func TestTheBuildersRecordNamesItsKeyAndFingerprint(t *testing.T) { + tm := aTierMesh(t, sdkdelivery.BuilderModule) + ctx := t.Context() + inv := tm.open.inventory + b := aBuilderOnTheBus(t) + p := tm.plan(t) + p.Commit = "c6c6c6c6c6c6c6c6" + p.Modules[sdkdelivery.BuilderModule] = &inventory.PlanModule{} + if err := inv.SavePlan(ctx, &p); err != nil { + t.Fatal(err) + } + fp := fpOf('9') + manifest := b.indexed(t, sdkdelivery.BuilderModule, "c6", "novox/mesh-catalog", "modules/"+sdkdelivery.BuilderModule, p.Commit, fp) + b.built(t, sdkdelivery.BuilderModule, "c6", fp, manifest, sdkdelivery.BuildDone) + becomes(t, 10*time.Second, "the builder taken from its key", func() bool { + advancePlans(ctx, tm.open) + return tm.plan(t).Modules[sdkdelivery.BuilderModule].State == "built" + }) + id := tm.plan(t).Modules[sdkdelivery.BuilderModule].Build + record, found, err := inv.BuildByID(ctx, id) + if err != nil || !found { + t.Fatalf("the builder's record %q: %v %v", id, found, err) + } + if !strings.HasPrefix(record.ID, "key-") || record.SourceFingerprint != fp || record.Module != sdkdelivery.BuilderModule { + t.Fatalf("the builder's record names %+v, not its key and fingerprint %s", record, fp) + } +} diff --git a/cmd/mesh-controller/walk_keys_scaffold_integration_test.go b/cmd/mesh-controller/walk_keys_scaffold_integration_test.go new file mode 100644 index 00000000..d957e9c0 --- /dev/null +++ b/cmd/mesh-controller/walk_keys_scaffold_integration_test.go @@ -0,0 +1,137 @@ +//go:build integration + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-controller/internal/builderkeys" + "github.com/novox/mesh-controller/internal/inventory" +) + +// A walk takes every module from the builder's done key and asks no build (R4g, ADR 0501, ADR 0503 §6). The tests of what +// a walk does around its builds (its tiers, sends, stops, retries, backups and waits for its delivery's word) need the +// builds to happen without a builder, so this stands one in: every module has a done key at once, and the take-in of a key +// records the build and registers the module as the real one does, in the caller's turn. The tests that are of the take-in +// itself (key_takein, walk_from_keys, first_builds) use a builder on a bus of their own (builder_on_the_bus_test.go). + +// keysAllDone is the indexer and the builder as a walk reads them when every module is built at every commit. +type keysAllDone struct { + mu sync.Mutex + paths [][3]string // repository, path and commit of each key taken in, in order + named []string // the same, as repository#id +} + +// fpOfModuleAt is the fingerprint a module has at a commit in these tests: one for each pair. +func fpOfModuleAt(module, commit string) string { + sum := sha256.Sum256([]byte(module + "@" + commit)) + return "src2:" + hex.EncodeToString(sum[:]) +} + +func (k *keysAllDone) ModuleAt(_ context.Context, module, commit string) (sdkdelivery.Module, error) { + return sdkdelivery.Module{FP2: fpOfModuleAt(module, commit), Commit: commit, Repository: "novox/mesh-catalog", + Path: "modules/" + module}, nil +} + +func (k *keysAllDone) Build(_ context.Context, module, fp string) (builderkeys.Key, error) { + digest := "sha256:" + fp[len("src2:"):] + return builderkeys.Key{Module: module, Fingerprint: fp, Found: true, Revision: 1, + Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, By: "ace", Artifacts: []sdkdelivery.BuiltArtifact{{ + Name: "a", Kind: "archive", Digest: digest, Reference: "artifact-store://" + module + "/a/blobs/" + digest}}}}, nil +} + +// take is the take-in of a key as the walk's look starts it: the build recorded and the module registered at the commit the +// walk carries. +func (k *keysAllDone) take(ctx context.Context, open *stores, module string, trunk sdkdelivery.Module, id string) { + inv := open.inventory + defer func() { + keyTakes.Lock() + delete(keyTakes.running, id) + keyTakes.taken[id] = true + keyTakes.Unlock() + askToLook() + }() + m := catalogue.Manifest{Module: module, Version: "1"} + if shelf, err := inv.Catalogue(ctx); err == nil { + if held, ok := shelf[module]; ok { + m = held + } + } + src, err := inv.SourceOf(ctx, module) + if err != nil { + src = inventory.Source{Repository: trunk.Repository, Seat: "git", Ref: "main", Path: keyPath(trunk)} + } + src.BuiltFrom, src.Head = trunk.Commit, trunk.Commit + raw, _ := json.Marshal(m) + if err := inv.RecordBuild(ctx, inventory.Build{ID: id, Module: module, Repository: src.Repository, Commit: trunk.Commit, + Path: src.Path, On: "ace", At: time.Now().UTC(), Manifest: raw, SourceFingerprint: trunk.FP2}); err != nil { + keepKeyWait(id, "the build could not be recorded: "+err.Error()) + return + } + if err := inv.RegisterModule(ctx, m, src); err != nil { + keepKeyWait(id, "the module could not be registered: "+err.Error()) + return + } + k.mu.Lock() + defer k.mu.Unlock() + k.paths = append(k.paths, [3]string{src.Repository, src.Path, trunk.Commit}) + k.named = append(k.named, src.Repository+"#"+id) +} + +// everyKeyDone has the walks of a test take every module from a done key, and says which were taken. +func everyKeyDone(t *testing.T) *keysAllDone { + t.Helper() + k := &keysAllDone{} + reset := func() { + keyTakes.Lock() + keyTakes.running, keyTakes.waits, keyTakes.taken = map[string]bool{}, map[string]string{}, map[string]bool{} + keyTakes.Unlock() + } + reset() + wasKeys, wasTake, wasBackground := openPlanKeys, takeKeyIn, inBackground + openPlanKeys = func() (planKeyReads, func(), error) { return k, func() {}, nil } + takeKeyIn = k.take + inBackground = func(work func()) { work() } + t.Cleanup(func() { + openPlanKeys, takeKeyIn, inBackground = wasKeys, wasTake, wasBackground + reset() + }) + return k +} + +// asksRecorded has the walks of a test take every module from a done key, and says each as repository#build id. +func asksRecorded(t *testing.T) *[]string { + t.Helper() + return &everyKeyDone(t).named +} + +// asksWithPaths is asksRecorded saying each as repository, path and the commit the walk carried. +func asksWithPaths(t *testing.T) *[][3]string { + t.Helper() + return &everyKeyDone(t).paths +} + +// asksOfEveryModule once made every module the builder's own, the one ask left; a walk asks nothing now. +func asksOfEveryModule(t *testing.T) { t.Helper() } + +// noBuildAsked once failed a test that asked build-agent for a build; nothing can now, so it says nothing. +func noBuildAsked(t *testing.T) { t.Helper() } + +// twoTiers registers two modules, b standing on a, each with a source a plan asks. +func twoTiers(t *testing.T, open *stores) { + t.Helper() + for _, name := range []string{"a", "b"} { + if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: name, Version: "1"}, + inventory.Source{Repository: "novox/" + name, Seat: "git", Ref: "main", BuiltFrom: "c0ffee", Head: "c0ffee"}); err != nil { + t.Fatal(err) + } + } +} diff --git a/cmd/mesh-controller/walk_said_integration_test.go b/cmd/mesh-controller/walk_said_integration_test.go index f53ed71c..25aba361 100644 --- a/cmd/mesh-controller/walk_said_integration_test.go +++ b/cmd/mesh-controller/walk_said_integration_test.go @@ -138,7 +138,7 @@ func TestAWalkRetriedByAPersonIsSaidStartedAgain(t *testing.T) { Created: before, State: inventory.PlanFailed, Tier: 0, Tiers: [][]string{{"a"}, {"b"}}, Note: "a failed to build in tier 0", Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1", - Why: link.KilledByHand}}, + Why: "killed by hand"}}, Times: &inventory.PlanTimes{Cut: &before, Started: &before, Ended: &ended, Class: inventory.ClassLeaf}} if err := open.inventory.SavePlan(ctx, &failed); err != nil { t.Fatal(err) @@ -177,35 +177,6 @@ func asTyped(t *testing.T, args ...string) error { return err } -func TestAWalkEndedByCancelOrClearIsSaidEnded(t *testing.T) { - _, ask := aBuildQueue(t) - open := aMesh(t) - next := planMovedHeard(t) - twoTiers(t, open) - for _, verb := range [][]string{{"cancel"}, {"clear"}} { - id := link.NewBuildID(time.Now()) - ask(id, "https://forge.example/novox/a.git") - asked, _ := link.BuildAskedAt(id) - walk := aStartedWalkAsking(t, open, "plan-308-"+verb[0], id, asked) - args := verb - if verb[0] == "cancel" { - args = append(args, id) - } - if err := asTyped(t, args...); err != nil { - t.Fatal(err) - } - ended := next(inventory.WalkEnded) - if ended["walk"] != walk.ID || ended["outcome"] != inventory.WalkFailed { - t.Fatalf("%s: the end is %v", verb[0], ended) - } - // Ended, and so the next walk may be cut on the same repository. - p, _ := open.inventory.PlanByID(t.Context(), walk.ID) - if p.State != inventory.PlanFailed { - t.Fatalf("%s left the walk %s", verb[0], p.State) - } - } -} - func TestAWalkStoppedByHandAndRetriedEndsFailedWhenItFailsAgain(t *testing.T) { open := aMesh(t) ctx := t.Context() @@ -234,8 +205,12 @@ func TestAWalkStoppedByHandAndRetriedEndsFailedWhenItFailsAgain(t *testing.T) { if p.State != inventory.PlanBuilding || (p.Delivery != nil && p.Delivery.Stopped != "") { t.Fatalf("%s: the walk is %s and keeps its stop: %+v", again[0], p.State, p.Delivery) } - asking, _ := link.BuildAskedAt(p.Modules["a"].Build) - planBuilt(ctx, open, "a", "c0ffee", "it broke again", asking, p.Modules["a"].Build) + // The build asked fails again: its record says so, and the look settles the walk from it. + if err := open.inventory.RecordBuild(ctx, inventory.Build{ID: p.Modules["a"].Build, Module: "a", Commit: "c0ffee", + Repository: "novox/a", At: time.Now().UTC(), Asked: before, Failed: "it broke again"}); err != nil { + t.Fatal(err) + } + advancePlans(ctx, open) if p, _ = open.inventory.PlanByID(ctx, walk.ID); p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed { t.Fatalf("%s: failing again, the walk is %s and ends %s", again[0], p.State, p.Outcome()) } diff --git a/cmd/mesh-controller/walk_wait_rebuild_754_test.go b/cmd/mesh-controller/walk_wait_rebuild_754_test.go index ee9616b3..97b134cb 100644 --- a/cmd/mesh-controller/walk_wait_rebuild_754_test.go +++ b/cmd/mesh-controller/walk_wait_rebuild_754_test.go @@ -8,39 +8,6 @@ import ( "github.com/novox/mesh-controller/internal/inventory" ) -// 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) { diff --git a/cmd/mesh-controller/watchdogs.go b/cmd/mesh-controller/watchdogs.go index c714678d..208c98da 100644 --- a/cmd/mesh-controller/watchdogs.go +++ b/cmd/mesh-controller/watchdogs.go @@ -79,9 +79,6 @@ type signalFacts struct { loop loopFacts loopErr error - asks []askFacts - asksErr error - calls []link.Call standings []conditions.Condition @@ -175,7 +172,6 @@ type planFacts struct { entered time.Time bound time.Duration waiting string - paused bool bus bool } @@ -195,12 +191,6 @@ type loopFacts struct { where string } -type askFacts struct { - id, seat, what, state, on string - since time.Time - bound time.Duration -} - type selfCheckFacts struct { last time.Time every time.Duration @@ -366,7 +356,6 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts { f.held, f.heldErr = gatherHeldMoves(ctx, inv) f.images, f.imagesErr = gatherMissingImagesHere(ctx, inv) f.loop, f.loopErr = w.gatherLoop() - f.asks, f.asksErr = w.gatherAsks(ctx, inv) if open, err := w.keeper.Open(ctx); err != nil { f.standingsErr = err } else { @@ -544,7 +533,6 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time, b if err != nil { return nil, nil, err } - pause := buildSeatPause(ctx, inv, plans) var out []planFacts var waits []waitFacts for _, p := range plans { @@ -565,11 +553,10 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time, b if waitingOnBackups(p) { continue } - _, paused := pausedWaiting(p, pause, now) bound := bounds.of(p.Repository) out = append(out, planFacts{id: p.ID, repository: p.Repository, commit: p.Commit, tier: p.Tier, tiers: len(p.Tiers), entered: inTierSince(p), bound: bound, - waiting: planLineWith(p, now, pause, bound), paused: paused, bus: byTheBus[p.ID]}) + waiting: planLineWith(p, now, bound), bus: byTheBus[p.ID]}) } return out, waits, nil } @@ -629,7 +616,7 @@ func p90(took []time.Duration) time.Duration { return sorted[int(0.9*float64(len(sorted)-1))] } -// loopConsumers are the controller's own consumers: the loop never takes from the build seat's check workers (issue 1063). +// loopConsumers are the controller's own consumers (issue 1063). func loopConsumers() []broker.Consumer { return broker.ControllerConsumers() } // gatherLoop is when the event loop last took a message and what its consumers hold. @@ -664,50 +651,6 @@ func readLoop(took time.Time, consumers []broker.Consumer, held func(broker.Cons return f, nil } -// gatherAsks is every ask in the build seat's queue that is in flight or dead, with its bound. -func (w *watchdogs) gatherAsks(ctx context.Context, inv *inventory.Inventory) ([]askFacts, error) { - if w.js == nil { - return nil, errors.New("this controller is not on the bus") - } - entries, err := inv.Catalogued(ctx) - if err != nil { - return nil, fmt.Errorf("the catalogue cannot be read: %w", err) - } - seat := buildSeatAmong(entries) - q, err := link.ReadQueue(ctx, w.js, seat) - if err != nil { - return nil, err - } - builds, err := inv.Durations(ctx, inventory.DurationBuild, time.Now().Add(-14*24*time.Hour)) - if err != nil { - return nil, fmt.Errorf("the measured builds cannot be read: %w", err) - } - var took []time.Duration - for _, d := range builds { - took = append(took, d.Took) - } - bound := askDefault - if len(took) > 0 { - bound = max(askAtLeast, 3*p90(took)) - } - var out []askFacts - for _, a := range q.Asks { - if a.State != link.AskInFlight && a.State != link.AskDead { - continue - } - since := a.Started - if since.IsZero() { - since = a.AskedAt - } - what := a.Repository - if a.Path != "" { - what += " " + a.Path - } - out = append(out, askFacts{id: a.ID, seat: seat, what: what, state: a.State, on: a.On, since: since, bound: bound}) - } - return out, nil -} - // lostConsumers is, of the consumers the bus said were deleted, each that is still missing and that // the mesh expects: a consumer removed because its module was unassigned is not lost. func (w *watchdogs) lostConsumers(ctx context.Context, heard []link.Advisory) (map[string]bool, error) { diff --git a/go.mod b/go.mod index bc54493b..699f18e1 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,11 @@ module github.com/novox/mesh-controller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 github.com/novox/mesh-host v0.0.0 - github.com/therootcompany/xz v1.0.1 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index bea58f3a..5ffa1878 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4= git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,8 +38,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= -github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= diff --git a/internal/artifacts/scratch.go b/internal/artifacts/scratch.go deleted file mode 100644 index cb20b972..00000000 --- a/internal/artifacts/scratch.go +++ /dev/null @@ -1,91 +0,0 @@ -package artifacts - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" -) - -// Listing and letting go of tagged documents: the check builds the scratch namespace keeps (novox/hq ADR 0350 -// §5). A check build is found by its tag, and the build seat's holder lets each go when its pull request closes -// or it is seven days old, so the store keeps a week of check builds and no more. - -// Tags is every tag repository holds, in the store's order. None for a repository the store does not hold. -func (s Store) Tags(ctx context.Context, repository string) ([]string, error) { - if s.Address == "" { - return nil, fmt.Errorf("this mesh has no artifact store on its network to list %s in", repository) - } - var tags []string - next := "http://" + s.Address + "/v2/" + repository + "/tags/list?n=1000" - for next != "" { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil) - if err != nil { - return nil, err - } - response, err := s.client().Do(request) - if err != nil { - return nil, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) - } - switch response.StatusCode { - case http.StatusOK: - case http.StatusNotFound: - response.Body.Close() - return tags, nil - default: - response.Body.Close() - return nil, fmt.Errorf("the artifact store answered %s for the tags of %s", response.Status, repository) - } - var page struct { - Tags []string `json:"tags"` - } - err = json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(&page) - link := response.Header.Get("Link") - response.Body.Close() - if err != nil { - return nil, fmt.Errorf("the tags of %s could not be read: %w", repository, err) - } - tags = append(tags, page.Tags...) - next = nextPage(link, s.Address) - } - return tags, nil -} - -// nextPage is the registry's `Link: ; rel="next"`, made whole; empty when there is no next page. -func nextPage(link, address string) string { - if !strings.Contains(link, `rel="next"`) { - return "" - } - start, end := strings.Index(link, "<"), strings.Index(link, ">") - if start < 0 || end <= start+1 { - return "" - } - target := link[start+1 : end] - if strings.HasPrefix(target, "/") { - return "http://" + address + target - } - return target -} - -// DeleteTagged lets go of the document tag names in repository: its manifest, by digest, so the next -// collection takes its layer. Gone when the tag names nothing. -func (s Store) DeleteTagged(ctx context.Context, repository, tag string) error { - if s.Address == "" { - return fmt.Errorf("this mesh has no artifact store on its network to let go of %s:%s in", repository, tag) - } - digest, err := s.manifestDigest(ctx, repository, tag) - if err != nil { - return err - } - if digest == "" { - return Gone - } - err = s.remove(ctx, s.url(repository, "manifests", digest), repository+":"+tag) - if errors.Is(err, Gone) { - return Gone - } - return err -} diff --git a/internal/artifacts/store.go b/internal/artifacts/store.go index e105f785..6f98e09c 100644 --- a/internal/artifacts/store.go +++ b/internal/artifacts/store.go @@ -291,3 +291,32 @@ func (s Store) HoldsManifest(ctx context.Context, reference string) (bool, error } return s.has(ctx, s.url(repository, kind, digest), anyManifest...) } + +// Holds is whether the store holds what a recorded reference names: the blob of an archive, the manifest of an image. +// Gone when it does not; asks and changes nothing (ADR 0526 clause 3: a registration by hand is refused at bytes that +// are not there). +func (s Store) Holds(ctx context.Context, reference string) error { + path, ours := catalogue.InArtifactStore(reference) + if !ours { + return fmt.Errorf("%w: %s", ErrNotOurs, reference) + } + repository, kind, digest, err := split(path) + if err != nil { + return err + } + if s.Address == "" { + return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference) + } + if kind == "blobs" { + _, err := s.blobSize(ctx, repository, digest) + return err + } + held, err := s.has(ctx, s.url(repository, kind, digest), anyManifest...) + if err != nil { + return err + } + if !held { + return Gone + } + return nil +} diff --git a/internal/beside/beside.go b/internal/beside/beside.go index b64f67cc..3ae47abf 100644 --- a/internal/beside/beside.go +++ b/internal/beside/beside.go @@ -8,7 +8,7 @@ // - In a merge check, the build seat clones each core repository beside the one checked (the catalogue // at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A // test judges against those clones, so agreement with the other repository is checked where -// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a +// `mesh/check` runs; a repository missing there fails the test, never skips it. Which clones a // check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host // by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's // go.mod pins. In a mesh where either module has no source repository nothing is @@ -73,6 +73,11 @@ func Dir(t testing.TB, repository string) string { } return os.WriteFile(into, raw, 0o644) }) + if err == nil { + // The copy stands at its repository's root, as the clone does: what is read from a repository's root + // (the catalogue's predefined.json, found above a manifest by its .git) is found in the copy too. + err = os.WriteFile(filepath.Join(dir, ".git"), nil, 0o644) + } if err != nil { t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err) } diff --git a/internal/broker/agreement_catalogue_test.go b/internal/broker/agreement_catalogue_test.go index 05c2c028..96eba076 100644 --- a/internal/broker/agreement_catalogue_test.go +++ b/internal/broker/agreement_catalogue_test.go @@ -105,7 +105,11 @@ func theCataloguesEvents(t *testing.T) ([]AnEmitter, []AConsumer, []DeclaredSeat emitters = append(emitters, *emitter) } if consumer != nil { - consumers = append(consumers, *consumer) + kept, setAside := setAsideRetiredSeats(*consumer) + for _, note := range setAside { + t.Log(note) + } + consumers = append(consumers, kept) } seats = append(seats, declared...) } @@ -115,6 +119,53 @@ func theCataloguesEvents(t *testing.T) ([]AnEmitter, []AConsumer, []DeclaredSeat return emitters, consumers, seats } +// setAsideRetiredSeats takes out of a consumer the events of a seat the mesh has retired, and says each one. +// The check judges against the catalogue's trunk (ADR 0484 clause 4), and a group that retires a seat while +// its catalogue pull request drops the consume (ADR 0524 section 5) meets the trunk still holding it: against +// that catalogue the seat is gone and the subscription falls back to a module subject nobody owns. It is set +// aside and logged, never silently dropped; an unknown seat that is not retired is still judged. +func setAsideRetiredSeats(c AConsumer) (AConsumer, []string) { + kept := AConsumer{Module: c.Module} + var notes []string + for _, want := range c.Consumes { + seat, event, named := strings.Cut(want, ".") + if replacement, retired := catalogue.RetiredSeats[seat]; named && retired { + notes = append(notes, c.Module+" still consumes "+seat+"."+event+"; the seat is retired (its work is "+ + replacement+"); the catalogue drops it in the same change (ADR 0524 §5), and the check judges against the catalogue's trunk (ADR 0484 clause 4)") + continue + } + kept.Consumes = append(kept.Consumes, want) + } + return kept, notes +} + +func TestARetiredSeatsConsumeIsSetAsideAndAnUnknownOneIsNot(t *testing.T) { + var retired string + for name := range catalogue.RetiredSeats { + retired = name + break + } + if retired == "" { + t.Skip("no seat is retired") + } + kept, notes := setAsideRetiredSeats(AConsumer{Module: "m", + Consumes: []string{retired + ".built", "no-such-seat.built", "plain"}}) + if len(notes) != 1 || !strings.Contains(notes[0], retired+".built") { + t.Fatalf("the retired seat's consume was not set aside and said: %v", notes) + } + if strings.Join(kept.Consumes, ",") != "no-such-seat.built,plain" { + t.Fatalf("what stays to be judged is wrong: %v", kept.Consumes) + } + if bad := Disagreements([]AnEmitter{{Module: "other", Emits: []string{"x"}}}, + []AConsumer{kept}, nil); len(bad) != 0 { + t.Fatalf("unexpected: %v", bad) + } + if bad := Disagreements([]AnEmitter{{Module: "no-such-seat", Emits: []string{"x"}}}, + []AConsumer{kept}, nil); len(bad) != 1 { + t.Fatalf("an unknown, non-retired consume no longer fails: %v", bad) + } +} + // **Do the derived subjects meet, not just the names?** // // The check above compares what a consumer asks for against what an emitter says it emits, by name. It diff --git a/internal/broker/announced.go b/internal/broker/announced.go index 5f559b88..dd0ec5f9 100644 --- a/internal/broker/announced.go +++ b/internal/broker/announced.go @@ -17,9 +17,10 @@ import ( const StatesAnnouncedBucket = announce.KeptBucket // controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares -// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, the checks running and the plans building. +// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets and the grants issued, which the assigner +// reads (ADR 0528 clause 2). var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, - ChecksRunning, PlansBuilding, MirrorDeclaredPackages} + MirrorDeclaredPackages, ControllerGrants} // ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local // name, the ones others may read marked readable. It announces itself, as any module does from its code. diff --git a/internal/broker/announced_test.go b/internal/broker/announced_test.go index 8b4c1167..f538c557 100644 --- a/internal/broker/announced_test.go +++ b/internal/broker/announced_test.go @@ -38,8 +38,8 @@ func TestTheControllerAnnouncesWhatItKeeps(t *testing.T) { readable = append(readable, k.Name) } } - want := []string{ChainBuilds, ChecksRunning, MirrorDeclaredConfig, PlansBuilding, MirrorPredefined, ChainRollouts, - MirrorAssignments, MirrorDeclaredPackages} + want := []string{ChainBuilds, MirrorDeclaredConfig, MirrorPredefined, ChainRollouts, + MirrorAssignments, MirrorDeclaredPackages, ControllerGrants} slices.Sort(want) if !slices.Equal(readable, want) { t.Fatalf("readable %v, want %v", readable, want) diff --git a/internal/broker/buildbacklog.go b/internal/broker/buildbacklog.go new file mode 100644 index 00000000..49f9b538 --- /dev/null +++ b/internal/broker/buildbacklog.go @@ -0,0 +1,47 @@ +package broker + +import "git.novox.be/novox/mesh-sdk/go/delivery" + +// The view alone reads the build seats' queues (novox/hq ADR 0525 §2, issue 1887 A8a): the shared workers' counts and the +// oldest waiting ask, to show the backlog. It may not publish an ask: only the builder and the checker ask, by their seat use. +// The watchman is granted nothing here: a waiting ask has no pulse, a taken one is read from the build module's steps (issue +// 1908). Nothing is a wildcard over a stream or a worker. +const ( + // ViewModule is the module of the delivery view (mesh-delivery-view). + ViewModule = "mesh-delivery-view" + // BuilderModule is the module of the builder (mesh-delivery-builder). + BuilderModule = "mesh-delivery-builder" +) + +// BuildAskReads are the API subjects the builder requests when it takes over, at its start, a key naming an ask (issue +// 1910): each build seat's stream's information and its messages by sequence, to tell whether the ask is on its stream. +// Reads only: no consumer, no pull, no acknowledgement; the builder puts its asks by its seat use, as before. +func BuildAskReads() []string { + var out []string + for _, lang := range delivery.AskLanguages { + if stream, err := delivery.AskStream(lang); err == nil { + out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream) + } + } + return out +} + +// BuildBacklogReads are the API subjects the view requests: the information of each build seat's stream and of each of its +// shared workers (delivery.AskStream, delivery.AskWorker), and the message of the stream by sequence. Reads only: no +// message is pulled, none acknowledged, none published. +func BuildBacklogReads() []string { + var out []string + for _, lang := range delivery.AskLanguages { + stream, err := delivery.AskStream(lang) + if err != nil { + continue + } + out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream) + for _, kind := range delivery.AskKinds { + if w, err := delivery.AskWorker(lang, kind); err == nil { + out = append(out, "$JS.API.CONSUMER.INFO."+stream+"."+w) + } + } + } + return out +} diff --git a/internal/broker/buildbacklog_test.go b/internal/broker/buildbacklog_test.go new file mode 100644 index 00000000..3aa99531 --- /dev/null +++ b/internal/broker/buildbacklog_test.go @@ -0,0 +1,88 @@ +package broker + +import ( + "strings" + "testing" +) + +func hasSubject(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func composedFor(t *testing.T, module string) (pub, sub []string) { + t.Helper() + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: module}) + if err != nil { + t.Fatal(err) + } + return perms.Publish, perms.Subscribe +} + +func TestTheWatchmanIsGrantedNothingOnTheBuildSeats(t *testing.T) { // 1908: a waiting ask has no pulse + pub, sub := composedFor(t, WatchmanModule) + for _, s := range append(append([]string{}, pub...), sub...) { + if strings.Contains(s, "node-build") || strings.Contains(s, "NODE_BUILD") { + t.Errorf("the watchman is granted %s on a build seat", s) + } + } +} + +func containsAny(list []string, sub string) bool { + for _, x := range list { + if strings.Contains(x, sub) { + return true + } + } + return false +} + +func TestTheViewReadsTheBuildSeatsWorkersByNameAndPublishesNoAsk(t *testing.T) { // 1887/A8a + pub, sub := composedFor(t, ViewModule) + for _, s := range []string{ + "$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO", "$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO", + "$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker", "$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_RECHECK_worker", + "$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_DELIVERY_worker", "$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE", + } { + if !hasSubject(pub, s) { + t.Errorf("the view cannot request %s", s) + } + } + for _, s := range append(append([]string{}, pub...), sub...) { + if strings.Contains(s, "mesh.seat.node-build") || (strings.Contains(s, "NODE_BUILD") && (strings.Contains(s, "MSG.NEXT") || strings.Contains(s, "$JS.ACK") || strings.Contains(s, "CONSUMER.CREATE"))) { + t.Errorf("the view may publish an ask or pull and acknowledge: %s", s) + } + } + perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: ViewModule}) + for _, s := range []string{"mesh.seat.node-build-go.accept.check", "$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker"} { + if MayPublish(perms, s) { + t.Errorf("the view may publish %s", s) + } + } + if other, _ := composedFor(t, "shop"); containsAny(other, "SEAT_NODE_BUILD") { + t.Errorf("another module reads the build seats: %v", other) + } +} + +// 1910: the builder may read each build seat's stream's information and its messages by sequence, by name, and may not +// pull, acknowledge or make a consumer there, nor list every stream. +func TestTheBuilderReadsTheBuildSeatsStreamsByName(t *testing.T) { + pub, _ := composedFor(t, BuilderModule) + for _, s := range []string{"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO", "$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO", + "$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE", "$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE"} { + if !hasSubject(pub, s) { + t.Errorf("the builder cannot request %s", s) + } + } + perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: BuilderModule}) + for _, s := range []string{"$JS.API.STREAM.NAMES", "$JS.API.STREAM.INFO.SEAT_OPERATOR_CHANNEL", + "$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker", "$JS.API.CONSUMER.CREATE.SEAT_NODE_BUILD_GO"} { + if MayPublish(perms, s) { + t.Errorf("the builder may publish %s", s) + } + } +} diff --git a/internal/broker/cancelled.go b/internal/broker/cancelled.go index afd00088..e69b2561 100644 --- a/internal/broker/cancelled.go +++ b/internal/broker/cancelled.go @@ -1,92 +1,11 @@ package broker -import ( - "context" - "fmt" - "strings" - "time" +import "strings" - "github.com/nats-io/nats.go/jetstream" -) +// A seat's cancelled set (novox/hq ADR 0219) is a bucket the mesh no longer raises or grants (R4g, ADR 0525 clause 6): the +// controller asks no work queue, so nothing is cancelled. A bus that still holds one is not reported as state nothing declares. -// A seat's cancelled set (novox/hq ADR 0219). -// -// **Cancelling an ask is deleting its message from the seat's work queue** — and that alone has a -// race no ordering on one side closes: a holder may fetch the ask in the moment between the -// controller reading the queue and deleting the message, and build what a person cancelled. So the -// controller first writes the ask's id into the seat's cancelled set, and a holder looks its ask up -// there after taking it and before building: listed, it terminates the ask and announces it failed -// rather than starting it. -// -// **A key-value bucket, because that is the shape the bus already has for "a small set the -// controller writes and many read cheaply"** (ADR 0201's state buckets): one direct read by key per -// ask taken — no consumer, no subscription a holder must keep, nothing that grows a holder's grants -// beyond one read subject. Kept beside the seat's own stream and worker and named like them, so the -// three objects of one work queue read as one family; asserted by the controller with the queue, -// and aged out with it — an id cancelled a week ago names an ask the queue no longer holds. - -// CancelledSetName is the bucket holding a seat's cancelled asks. -func CancelledSetName(seat string) string { return "SEAT_" + upperSnake(seat) + "_cancelled" } - -// hasCancelledSet is whether a seat's queue can be cancelled from: the work queues the controller -// asks, whose asks it alone shows and changes. A module's own seat's queue is that module's affair. -func hasCancelledSet(seat string) bool { - for _, s := range seatsTheControllerAsks { - if s == seat { - return true - } - } - return false -} - -// IsCancelledSet says a bucket is a seat's cancelled set rather than a module's state — the mesh's -// own, and never one to report as state nothing declares. +// IsCancelledSet says a bucket is a seat's cancelled set rather than a module's state. func IsCancelledSet(bucket string) bool { return strings.HasPrefix(bucket, "SEAT_") && strings.HasSuffix(bucket, "_cancelled") } - -// cancelledSetAge is how long a cancelled id is kept: as long as the seat's queue keeps an ask. -const cancelledSetAge = 7 * 24 * time.Hour - -// A CancelledSetAsserter is what raising the cancelled sets needs of a connection. -type CancelledSetAsserter interface { - EnsureCancelledSet(seat string) error -} - -// RaiseCancelledSets asserts the cancelled set of every work queue the controller asks. -func RaiseCancelledSets(a CancelledSetAsserter, seats []DeclaredSeat) error { - for _, s := range seats { - if len(s.Accepts) == 0 || !hasCancelledSet(s.Name) { - continue - } - if err := a.EnsureCancelledSet(s.Name); err != nil { - return fmt.Errorf("asserting the cancelled set of %s: %w", s.Name, err) - } - } - return nil -} - -// EnsureCancelledSet creates a seat's cancelled set if absent and brings its options to match. -// Direct reads on, which is how a holder looks an id up with one request. -func (j *JetStream) EnsureCancelledSet(seat string) error { - js, err := jetstream.New(j.conn) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: CancelledSetName(seat), - Description: fmt.Sprintf("the asks of the %s seat cancelled by hand (novox/hq ADR 0219): written "+ - "by the controller before it deletes an ask from the queue, read by a holder on taking one, "+ - "so an ask fetched in that moment is ended rather than built", seat), - History: 1, - TTL: cancelledSetAge, - MaxValueSize: 4 * 1024, - MaxBytes: 4 * 1024 * 1024, - Storage: jetstream.FileStorage, - }); err != nil { - return fmt.Errorf("asserting bucket %s: %w", CancelledSetName(seat), err) - } - return nil -} diff --git a/internal/broker/cancelled_test.go b/internal/broker/cancelled_test.go index 5bf4a0a6..629d1ced 100644 --- a/internal/broker/cancelled_test.go +++ b/internal/broker/cancelled_test.go @@ -2,60 +2,6 @@ package broker import "testing" -// A build agent reads its seat's cancelled set by key and nothing more, answers its verbs on its own -// machine's subjects, and says whether it is paused under its own machine's name; the controller may -// ask any machine's holder its verbs (novox/hq ADR 0219). -func TestTheBuildQueueIsControlledWithTheGrantsItNeedsAndNoMore(t *testing.T) { - seat := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, - Emits: []string{"started", "built", "log.*", "paused.*"}, - Serves: []string{"current", "kill", "pause", "resume"}} - holder, err := PermissionsFor(Principal{Kind: KindModule, Node: "ace", Module: "build-agent", - Holds: []Seat{seat}, PasswordHash: "x"}) - if err != nil { - t.Fatal(err) - } - has(t, holder.Publish, "$JS.API.DIRECT.GET.KV_SEAT_NODE_BUILD_AGENT_cancelled.$KV.SEAT_NODE_BUILD_AGENT_cancelled.>") - hasNot(t, holder.Publish, "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>") - has(t, holder.Publish, "mesh.seat.node-build-agent.event.paused.ace") - hasNot(t, holder.Publish, "mesh.seat.node-build-agent.event.paused.*") - has(t, holder.Publish, "mesh.seat.node-build-agent.event.log.*") - has(t, holder.Subscribe, "mesh.seat.node-build-agent.tool.kill.ace") - hasNot(t, holder.Subscribe, "mesh.seat.node-build-agent.tool.kill.g14") - - // A module's own seat's queue is its own affair: no cancelled set, no grant for one. - other, _ := PermissionsFor(Principal{Kind: KindModule, Node: "ace", Module: "telegram", - Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}, PasswordHash: "x"}) - hasNot(t, other.Publish, "$JS.API.DIRECT.GET.KV_SEAT_TELEGRAM_SENDER_cancelled.$KV.SEAT_TELEGRAM_SENDER_cancelled.>") - - controller, _ := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"}) - has(t, controller.Publish, "mesh.seat.node-build-agent.tool.>") - - if CancelledSetName("node-build-agent") != "SEAT_NODE_BUILD_AGENT_cancelled" || - !IsCancelledSet("SEAT_NODE_BUILD_AGENT_cancelled") || IsCancelledSet("build-agent_cancelled") { - t.Error("the cancelled set is not named as its seat's family") - } -} - -// Only the work queues the controller asks get a cancelled set. -func TestOnlyTheControllersQueuesHaveACancelledSet(t *testing.T) { - var asserted []string - a := asserterFunc(func(seat string) error { asserted = append(asserted, seat); return nil }) - if err := RaiseCancelledSets(a, []DeclaredSeat{ - {Name: "node-build-agent", Accepts: []string{"build"}}, - {Name: "telegram-sender", Accepts: []string{"send"}}, - {Name: "mesh-controller"}, - }); err != nil { - t.Fatal(err) - } - if len(asserted) != 1 || asserted[0] != "node-build-agent" { - t.Fatalf("asserted %v", asserted) - } -} - -type asserterFunc func(string) error - -func (f asserterFunc) EnsureCancelledSet(seat string) error { return f(seat) } - // A seat's cancelled set is never reported as state nothing declares. func TestACancelledSetIsNotUndeclaredState(t *testing.T) { undeclared, err := RaiseBuckets(fakeBuckets{names: []string{"SEAT_NODE_BUILD_AGENT_cancelled", "gone_state"}}, nil) diff --git a/internal/broker/checks.go b/internal/broker/checks.go deleted file mode 100644 index cb3b16ea..00000000 --- a/internal/broker/checks.go +++ /dev/null @@ -1,127 +0,0 @@ -package broker - -import "strings" - -// The pull requests' checks have work queues of their own (novox/hq ADR 0406 §3, issue 974). -// -// A delivery build goes on the build seat's own queue, `mesh.seat.node-build-agent.accept.build`. A check of a pull -// request marked ready goes on a second work-queue stream and every other check on a third. A builder takes from the -// first, then the second, then the third; the others are taken in the order asked. **Each check stream has one subject -// per pull request and keeps only the newest request** (a limit of one message a subject, the older discarded), so a -// newer request replaces one still waiting and a pull request has at most one waiting check: the stream's rule, not a -// pass of the controller that moves asks about. Nothing pauses the worker or deletes and republishes a request. - -const ( - // ChecksReadyStream holds the checks of pull requests marked ready; ChecksOtherStream every other check. - ChecksReadyStream = "CHECKS_READY" - ChecksOtherStream = "CHECKS_OTHER" - // ChecksReadyWorker and ChecksOtherWorker are the durable consumers the build seat's holders share on them, as - // they share the seat's worker on its own queue (novox/hq ADR 0190). - ChecksReadyWorker = "CHECKS_READY_worker" - ChecksOtherWorker = "CHECKS_OTHER_worker" - // TheBuildSeat is the role whose checks these streams queue. The retired build role's checks stay on its own queue. - TheBuildSeat = "node-build-agent" -) - -// PullsBucket is mesh-delivery's key per pull request: its head, the trunk commit its current check is asked against and -// its verdict (ADR 0406 §4). A running check watches its key. -var PullsBucket = BucketName("mesh-delivery", "pulls") - -// CheckSubjectPrefix is where a seat's checks of one kind are asked: `mesh.seat..check.ready.` or `.other.`. The -// `check` token keeps the subjects out of the seat's own queue, which takes `accept.>`. -func CheckSubjectPrefix(seat string, ready bool) string { - kind := "other" - if ready { - kind = "ready" - } - return "mesh.seat." + seat + ".check." + kind + "." -} - -// CheckStreams are the two check queues, in the order a builder takes from them. -func CheckStreams() []Stream { - return []Stream{ - { - Name: ChecksReadyStream, - Subjects: []string{CheckSubjectPrefix(TheBuildSeat, true) + ">"}, - Retention: RetentionWorkQueue, - MaxAge: 7 * 24 * 60 * 60, - MaxMsgsPerSubject: 1, - Direct: true, - Why: "the checks of pull requests marked ready, taken after a delivery build and before any other check " + - "(novox/hq ADR 0406 §3); one subject a pull request, the newest request kept, so a newer one replaces a " + - "request still waiting", - }, - { - Name: ChecksOtherStream, - Subjects: []string{CheckSubjectPrefix(TheBuildSeat, false) + ">"}, - Retention: RetentionWorkQueue, - MaxAge: 7 * 24 * 60 * 60, - MaxMsgsPerSubject: 1, - Direct: true, - Why: "every other check, taken last and in the order asked (novox/hq ADR 0406 §3); one subject a pull " + - "request, the newest request kept; one left behind for a head already judged is dropped when taken", - }, - } -} - -// CheckWorkers are the consumers the build seat's holders pull the check queues through: as the seat's own worker is, -// one for every holder, one ask at a time, acknowledged when the check is done. -func CheckWorkers() []Consumer { - worker := func(name, stream string, ready bool) Consumer { - return Consumer{ - Name: name, - Stream: stream, - Filters: []string{CheckSubjectPrefix(TheBuildSeat, ready) + ">"}, - AckWaitSeconds: 60, - MaxDeliver: 5, - Why: "every holder of " + TheBuildSeat + " pulls one check at a time from this worker and acknowledges after " + - "the check is done, so a crash mid-check redelivers rather than loses (novox/hq ADR 0406 §3)", - } - } - return []Consumer{ - worker(ChecksReadyWorker, ChecksReadyStream, true), - worker(ChecksOtherWorker, ChecksOtherStream, false), - } -} - -// holderCheckGrants are what a holder of the build seat is granted to take checks: ask about the two workers, pull from -// them and acknowledge, and nothing of the subjects themselves. -func holderCheckGrants() []string { - var pub []string - for _, w := range CheckWorkers() { - pub = append(pub, - "$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Name, - "$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Name, - "$JS.ACK."+w.Stream+"."+w.Name+".>") - } - // And the bucket of the checks they are running: each writes the key of its own check, reads another's, and the bucket - // is a controller's own bucket the build seat's holders write by design (ADR 0406 §3). - pub = append(pub, - "$KV."+CheckRunningBucket+".>", - "$JS.API.STREAM.INFO.KV_"+CheckRunningBucket, - "$JS.API.DIRECT.GET.KV_"+CheckRunningBucket+".>") - // And, to hold checks while a delivery builds (ADR 0483, issues 1687 and 1688): the bucket of the plans building, **read and - // never written** (the controller's alone, so no `$KV.` subject of it is granted), and the oldest waiting ask of each check - // queue, read by a direct get that takes nothing from the queue and so counts against no delivery of the ask. - pub = append(pub, readGrants(PlansBuildingBucket, "")...) - for _, w := range CheckWorkers() { - pub = append(pub, "$JS.API.DIRECT.GET."+w.Stream, "$JS.API.DIRECT.GET."+w.Stream+".>") - } - return pub -} - -// IsCheckStream says a stream is one of the check queues. -func IsCheckStream(stream string) bool { - return stream == ChecksReadyStream || stream == ChecksOtherStream -} - -// isCheckAsk says a message of a check queue is an ask the controller itself makes. -func isCheckAsk(stream, subject string) bool { - switch stream { - case ChecksReadyStream: - return strings.HasPrefix(subject, CheckSubjectPrefix(TheBuildSeat, true)) - case ChecksOtherStream: - return strings.HasPrefix(subject, CheckSubjectPrefix(TheBuildSeat, false)) - } - return false -} diff --git a/internal/broker/checks_running_mirror_test.go b/internal/broker/checks_running_mirror_test.go deleted file mode 100644 index aec691ea..00000000 --- a/internal/broker/checks_running_mirror_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package broker - -import ( - "slices" - "testing" -) - -// 1155/AC1: a module whose manifest reads mesh-controller.checks-running may watch and get every key of the bucket and -// write none. -func TestAModuleReadingChecksRunningReadsAndWritesNone(t *testing.T) { // 1155/AC1 - if !slices.Contains(controllerReadableStates(), "checks-running") || BucketName(ControllerSeat, "checks-running") != CheckRunningBucket { - t.Fatalf("a module cannot name %s in `reads`: %v", CheckRunningBucket, controllerReadableStates()) - } - perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-watchman", PasswordHash: "x", - Reads: []string{"mesh-controller.checks-running"}}) - if err != nil { - t.Fatal(err) - } - stream := "KV_" + CheckRunningBucket - has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream) - has(t, perms.Publish, "$JS.API.DIRECT.GET."+stream+".>") - has(t, perms.Publish, "$JS.API.CONSUMER.CREATE."+stream+".>") - for _, p := range perms.Publish { - if SubjectsOverlap(p, "$KV."+CheckRunningBucket+".any.key") { - t.Errorf("the reading module may write %s through %q", CheckRunningBucket, p) - } - } -} - -// 1155/AC3: the writers table names checks-running's writer as the build seat's holders, and the rule that a mirror is -// written by the controller alone names it as the exception, with its reason. -func TestChecksRunningIsAMirrorWrittenByTheBuildSeatsHolders(t *testing.T) { // 1155/AC3 - why := MirrorsWrittenByAnother["checks-running"] - if why == "" { - t.Fatal("the mirrors written by another do not name checks-running with a reason") - } - subject := "$KV." + CheckRunningBucket + ".novox.a.7" - var rows int - for _, row := range WritersTable { - for _, s := range row.Subjects { - if s != "$KV."+CheckRunningBucket+".>" { - continue - } - rows++ - holder := Principal{Kind: KindModule, Module: "builder", Node: "one", Holds: []Seat{{Name: TheBuildSeat}}} - other := Principal{Kind: KindModule, Module: "mesh-delivery-watchman", Node: "one"} - if row.Writes == nil || !row.Writes(holder, subject) || row.Writes(other, subject) { - t.Errorf("%q is not written by the build seat's holders alone", row.State) - } - } - } - if rows != 1 { - t.Fatalf("%d rows of the writers table write %s, want one", rows, CheckRunningBucket) - } - for m := range MirrorsWrittenByAnother { - if m != "checks-running" { - t.Errorf("the mirror %q is written by another and is not checks-running", m) - } - } -} diff --git a/internal/broker/checks_test.go b/internal/broker/checks_test.go deleted file mode 100644 index 9fd3e095..00000000 --- a/internal/broker/checks_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package broker - -import "testing" - -// The build agent's credential is the module principal `.build-agent` (Username, KindModule): its manifest's -// `reads: ["mesh-delivery.pulls"]` reaches that principal as a read and a watch of mesh-delivery's bucket of pull requests' -// keys — the direct get of a key, the consumer a watch makes, the stream's info — and as no write and no direct -// subscription of the bucket's subjects (novox/hq ADR 0406 §4, issue 974). -func TestTheBuildAgentReadsAndWatchesThePullRequestsKeys(t *testing.T) { - seat := Seat{Name: TheBuildSeat, Scope: "node", Accepts: []string{"build"}, Emits: []string{"built", "started"}} - p := Principal{Kind: KindModule, Node: "one", Module: "build-agent", Holds: []Seat{seat}, - Reads: []string{"mesh-delivery.pulls"}, PasswordHash: "x"} - if got := p.Username(); got != "one.build-agent" { - t.Fatalf("the build agent's credential is %q", got) - } - perms, err := PermissionsFor(p) - if err != nil { - t.Fatal(err) - } - for _, s := range []string{ - "$JS.API.STREAM.INFO.KV_mesh-delivery_pulls", - "$JS.API.DIRECT.GET.KV_mesh-delivery_pulls.>", - "$JS.API.CONSUMER.CREATE.KV_mesh-delivery_pulls.>", - "$JS.API.CONSUMER.DELETE.KV_mesh-delivery_pulls.>", - // And the two check workers it takes from. - "$JS.API.CONSUMER.MSG.NEXT.CHECKS_READY.CHECKS_READY_worker", - "$JS.API.CONSUMER.MSG.NEXT.CHECKS_OTHER.CHECKS_OTHER_worker", - "$JS.ACK.CHECKS_OTHER.CHECKS_OTHER_worker.>", - // And the bucket of the checks running, which it writes and reads (ADR 0406 §3). - "$KV." + CheckRunningBucket + ".>", - "$JS.API.DIRECT.GET.KV_" + CheckRunningBucket + ".>", - } { - has(t, perms.Publish, s) - } - hasNot(t, perms.Publish, "$KV."+PullsBucket+".>") - hasNot(t, perms.Publish, "mesh.seat.node-build-agent.check.>") - for _, s := range perms.Subscribe { - if s == "$KV."+PullsBucket+".>" { - t.Fatalf("the build agent subscribes the bucket's subjects directly: %v", perms.Subscribe) - } - } -} - -// Issue 1687 (ADR 0483 clause 2): a holder of the build seat reads the bucket of the plans building and writes none of it, and -// reads the oldest waiting ask of each check queue by a direct get, which takes nothing from the queue. -func TestTheBuildSeatsHoldersReadThePlansBuildingAndWriteNone(t *testing.T) { - seat := Seat{Name: TheBuildSeat, Scope: "node", Accepts: []string{"build"}, Emits: []string{"built", "started"}} - p := Principal{Kind: KindModule, Node: "one", Module: "build-agent", Holds: []Seat{seat}, PasswordHash: "x"} - perms, err := PermissionsFor(p) - if err != nil { - t.Fatal(err) - } - stream := "KV_" + PlansBuildingBucket - for _, s := range []string{ - "$JS.API.STREAM.INFO." + stream, - "$JS.API.DIRECT.GET." + stream + ".>", - "$JS.API.CONSUMER.CREATE." + stream + ".>", - "$JS.API.DIRECT.GET.CHECKS_READY", - "$JS.API.DIRECT.GET.CHECKS_OTHER", - } { - has(t, perms.Publish, s) - } - for _, s := range perms.Publish { - if SubjectsOverlap(s, "$KV."+PlansBuildingBucket+".plan-1") { - t.Errorf("a holder of the build seat may write %s through %q", PlansBuildingBucket, s) - } - } - // And the controller alone writes it, as the writers table says. - for _, row := range WritersTable { - for _, s := range row.Subjects { - if s == "$KV."+PlansBuildingBucket+".>" && row.Writes(p, "$KV."+PlansBuildingBucket+".plan-1") { - t.Errorf("%q lets the build seat's holder write the plans building", row.State) - } - } - } -} diff --git a/internal/broker/controller_buckets.go b/internal/broker/controller_buckets.go index 0462f01d..f7dd0a2b 100644 --- a/internal/broker/controller_buckets.go +++ b/internal/broker/controller_buckets.go @@ -50,39 +50,12 @@ var ( // controller's watcher hearing the expiry ends the wait and names the machine silent. Written by the lease // holder alone. RolloutSilentBucket = BucketName(ControllerSeat, "rollout-silent") - // CheckRunningBucket holds the pull request checks the builders are running now (novox/hq ADR 0406 §3, issue 974): per - // pull request a key `..` (or `group.`) holding the head and trunk commit being checked, the - // builder and its build. A builder puts it when it starts a check and again on each heartbeat, and deletes it when the - // check ends; a key not renewed within the bucket's age is a builder that died, and is gone. A builder that takes a - // waiting request for a head and trunk commit a key names drops it. **Written by the build seat's holders, several by - // design** — each the key of the check it runs. - CheckRunningBucket = BucketName(ControllerSeat, ChecksRunning) // DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged // on (novox/hq ADR 0420 §7, issue 1025): per merge a key `///` holding the // announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new // repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of // those heads. Written by the controller alone, from the announcement, when it holds the lease. DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges") - // PlansBuildingBucket holds one key per delivery plan in its build phase (novox/hq ADR 0483 clause 2, issue 1687): the key - // is `` (`plan-`), its value the plan, the time its build phase began and the time of the last refresh. - // The controller (lease holder) puts it when the plan's first build is asked, puts it again about every thirty seconds - // while the plan builds, and deletes it when the phase ends (every build of the last tier built, or the plan fails, stops - // or waits on the operator); the bucket's age, PlansBuildingKeptFor, is how long a key outlives a controller that - // stopped. **Read by the build seat's holders and the checker, written by the controller alone**: a holder takes no new - // check while any key is there, but for one that waited past ADR 0483's bound. A reader lists the keys and reads none of - // their values to decide that (`link.BuildingPlans`). - PlansBuildingBucket = BucketName(ControllerSeat, PlansBuilding) -) - -// PlansBuilding is the local name of the bucket of the plans building, as a reader names it in `reads` -// (`mesh-controller.plans-building`). -const PlansBuilding = "plans-building" - -// PlansBuildingKeptFor is the age of a plan-building key: the controller puts it again at PlansBuildingRefresh, so a -// controller that stopped leaves checks held at most this long (ADR 0483 clause 2). -const ( - PlansBuildingKeptFor = 2 * time.Minute - PlansBuildingRefresh = 30 * time.Second ) // DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are. @@ -152,21 +125,23 @@ const ( ChainAssignmentBytes = 64 << 20 ) +// GrantsBucket holds, keyed `.`, each fingerprint of a module whose bus credential the controller +// has issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): written by the controller alone once the bus's +// user list composed with the module's manifest at that fingerprint was sent to the bus's machine; read by the assigner, +// whose sixth rule moves a node's want to a fingerprint only once its grant exists. The name and layout are mesh-sdk's +// (delivery.ControllerGrantsBucket, ControllerGrantKey). +var GrantsBucket = BucketName(ControllerSeat, ControllerGrants) + +// ControllerGrants is the grants bucket's local name, as a reader names it in `reads` after `mesh-controller.`. +const ControllerGrants = "grants" + // ChainBuckets are the three buckets of the chain: builds, rollouts and assignments. func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} } // MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule // that a mirror is written by the controller alone (TestTheMirrorsListAndTheWritersTableAgree) has these exceptions and no // other. -var MirrorsWrittenByAnother = map[string]string{ - ChecksRunning: "the checks running are said by the builders themselves: every machine holding the build seat puts the " + - "key of the check it runs, at its start and at each heartbeat, and deletes it when the check ends, so the " + - "controller asserts the bucket and writes none of its keys (ADR 0406 §3); it is readable through `reads` so " + - "the watchman sees a check that stopped (ADR 0433 §6)", -} - -// ChecksRunning is the local name of the bucket of the checks running, as a reader names it in `reads`. -const ChecksRunning = "checks-running" +var MirrorsWrittenByAnother = map[string]string{} // The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket. const ( @@ -189,10 +164,6 @@ const RolloutSilentMarkerTTL = time.Minute // takes nothing live. const RolloutSilentKeptFor = time.Hour -// CheckRunningKeptFor is the age of a running check's key: a few heartbeats (ten seconds each) of a builder, so a builder that -// died is forgotten within two minutes and the check it ran is taken again then. -const CheckRunningKeptFor = 2 * time.Minute - // AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own. const AskedKeptFor = 30 * 24 * time.Hour @@ -222,16 +193,17 @@ func IsControllerBucket(bucket string) bool { bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket || bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket || bucket == DeclaredPackagesBucket || - bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket || + bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket || - bucket == PlansBuildingBucket || bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket + bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket || + bucket == GrantsBucket } // ControllerBuckets are the controller's own buckets, in the order they are asserted. func ControllerBuckets() []string { return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket, - AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket, - DeliveryMergesBucket, PlansBuildingBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket} + AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket, MergeWindowBucket, RolloutSilentBucket, + DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, GrantsBucket} } // ControllerBucketsAsserter is what raising the controller's buckets needs of a connection. @@ -359,37 +331,6 @@ func (j *JetStream) EnsureControllerBuckets() error { }); err != nil { return fmt.Errorf("asserting bucket %s: %w", RolloutSilentBucket, err) } - // **The checks running: a key that ages** (ADR 0406 §3, issue 974). One message a key, each renewal a new one, so the - // age of the bucket is how long a key outlives the builder that stopped renewing it. - if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: CheckRunningBucket, - Description: "the pull request checks the builders are running now (novox/hq ADR 0406): `` holds the " + - "head and trunk commit, the builder and its build; put by the builder when it starts and at each heartbeat, " + - "deleted when the check ends, gone within two minutes of a builder that died; written by the build seat's holders", - History: 1, - TTL: CheckRunningKeptFor, - MaxValueSize: 4 << 10, - MaxBytes: 8 << 20, - Storage: jetstream.FileStorage, - }); err != nil { - return fmt.Errorf("asserting bucket %s: %w", CheckRunningBucket, err) - } - // **The plans building: keys that age** (ADR 0483 clause 2, issue 1687). One message a key, each refresh a new one, so the - // age of the bucket is how long a key outlives a controller that stopped refreshing it. - if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: PlansBuildingBucket, - Description: "the delivery plans in their build phase (novox/hq ADR 0483): `` holds the plan and when its " + - "build phase began; put when the plan's first build is asked and about every thirty seconds, deleted when the " + - "phase ends, gone within two minutes of a controller that stopped; read by the build seat's holders, which " + - "take no new check while a key is there; written by the controller alone", - History: 1, - TTL: PlansBuildingKeptFor, - MaxValueSize: 4 << 10, - MaxBytes: 8 << 20, - Storage: jetstream.FileStorage, - }); err != nil { - return fmt.Errorf("asserting bucket %s: %w", PlansBuildingBucket, err) - } // **The states each module announced** (novox/hq ADR 0495 clauses 2 and 3, issue 1790): one key a module, its last word. if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ Bucket: StatesAnnouncedBucket, @@ -402,6 +343,20 @@ func (j *JetStream) EnsureControllerBuckets() error { }); err != nil { return fmt.Errorf("asserting bucket %s: %w", StatesAnnouncedBucket, err) } + // **The grants issued: no age** (ADR 0528 clause 2, issue 1394). One key a module's fingerprint, never removed: a grant + // that expired would hold back a want whose credential exists. + if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: GrantsBucket, + Description: "each fingerprint of a module whose bus credential the controller issued (novox/hq ADR 0528): " + + "`.` holds the accounts issued, put once the bus's user list composed with the module's " + + "manifest at it was sent; read by the assigner through `reads`; written by the controller alone", + History: delivery.ControllerGrantsHistory, + MaxValueSize: 4 << 10, + MaxBytes: 64 << 20, + Storage: jetstream.FileStorage, + }); err != nil { + return fmt.Errorf("asserting bucket %s: %w", GrantsBucket, err) + } // **The merges mesh-delivery announced: keys that age** (ADR 0420 §7, issue 1025). One message a key; a week. if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ Bucket: DeliveryMergesBucket, diff --git a/internal/broker/controller_buckets_test.go b/internal/broker/controller_buckets_test.go index f0637377..799a46d7 100644 --- a/internal/broker/controller_buckets_test.go +++ b/internal/broker/controller_buckets_test.go @@ -20,11 +20,6 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) { want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>", "$KV." + AssignmentsBucket + ".>", "$KV." + DeclaredConfigBucket + ".>", "$KV." + PredefinedBucket + ".>"} - for _, seat := range seatsTheControllerAsks { - if hasCancelledSet(seat) { - want = append(want, "$KV."+CancelledSetName(seat)+".>") - } - } for _, subject := range want { if !slices.Contains(p.Publish, subject) { t.Errorf("the controller may not publish %s, so it cannot write that bucket", subject) @@ -120,7 +115,7 @@ func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) { module := Principal{Kind: KindModule, Module: ControllerSeat, Node: "one"} rowed := map[string]bool{} for _, row := range WritersTable { - if !strings.HasSuffix(row.State, " mirror") && !strings.HasSuffix(row.State, " of the chain") { + if !strings.HasSuffix(row.State, " mirror") && !strings.HasSuffix(row.State, " of the chain") && row.State != "the grants issued" { continue } for _, subject := range row.Subjects { diff --git a/internal/broker/derived.go b/internal/broker/derived.go index 6aa1eaf1..7046af76 100644 --- a/internal/broker/derived.go +++ b/internal/broker/derived.go @@ -120,6 +120,8 @@ type DeclaredSeat struct { // Serves are the verbs the holder answers, request and reply. Serves []string RetainSeconds int + // PulledByKind is Seat's: a worker per accepted kind, derived from the holders' seat traffic, not here. + PulledByKind bool } // ConsumerFor is the durable consumer a module's declarations imply, or false when it subscribes @@ -258,7 +260,9 @@ func ConsumersOf(users []Principal) []ModuleConsumer { // redelivery: nothing is delivered that nobody asked for. A long build keeps its own ask alive // (stillWorking); the ack wait is for a holder that died. func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) { - if len(seat.Accepts) == 0 { + if len(seat.Accepts) == 0 || seat.PulledByKind { + // A seat pulled by kind has a worker per kind, which its holders' seat traffic derives (SeatTrafficObjects): one + // worker filtered on every accept would overlap them on the work queue. return Consumer{}, false } return Consumer{ diff --git a/internal/broker/genesis_template_test.go b/internal/broker/genesis_template_test.go index 0f25ea63..bcf1cf1f 100644 --- a/internal/broker/genesis_template_test.go +++ b/internal/broker/genesis_template_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strings" "testing" @@ -44,6 +45,12 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T) } // And nothing wider than what it derives, or genesis quietly grants a privilege the composition // takes away again on the first push. + // Except the retired build seats' grants, which the installer's list still names until genesis stops installing the + // builder (issue 1152): the controller derives none of them (R4g, ADR 0525 §10), the first push takes them away. + carriedPub = slices.DeleteFunc(slices.Clone(carriedPub), func(s string) bool { + return strings.HasPrefix(s, "mesh.seat.node-build-agent.") || strings.HasPrefix(s, "mesh.seat.mesh-build-machine.") || + strings.HasPrefix(s, "$KV.SEAT_NODE_BUILD_AGENT_") || strings.HasPrefix(s, "$KV.SEAT_MESH_BUILD_MACHINE_") + }) if diff := missing(carriedPub, want.Publish); len(diff) > 0 { t.Errorf("the installer's user list lets the controller publish %v, which it does not derive", diff) } diff --git a/internal/broker/grants_test.go b/internal/broker/grants_test.go new file mode 100644 index 00000000..7ebd3ce5 --- /dev/null +++ b/internal/broker/grants_test.go @@ -0,0 +1,44 @@ +package broker + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Issue 1394/C9a (ADR 0528 clause 2): the grants bucket is mesh-sdk's, one of the controller's own buckets asserted at its +// start, written by the controller alone; the assigner, naming mesh-controller.grants in `reads`, reads it and writes +// nothing of it. +func TestTheAssignerReadsTheGrantsAndWritesNone(t *testing.T) { + if GrantsBucket != delivery.ControllerGrantsBucket { + t.Fatalf("the grants bucket is %s, mesh-sdk names %s", GrantsBucket, delivery.ControllerGrantsBucket) + } + if !slices.Contains(controllerReadableStates(), ControllerGrants) { + t.Fatalf("a module cannot name %s in `reads`: %v", GrantsBucket, controllerReadableStates()) + } + if _, other := MirrorsWrittenByAnother[ControllerGrants]; other { + t.Error("the controller writes the grants itself") + } + if !IsControllerBucket(GrantsBucket) || !slices.Contains(ControllerBuckets(), GrantsBucket) { + t.Errorf("%s is not among the controller's buckets", GrantsBucket) + } + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "mesh-delivery-assigner", PasswordHash: "x", + Reads: []string{"mesh-controller.grants"}}) + if err != nil { + t.Fatal(err) + } + stream := "KV_" + GrantsBucket + has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream) + has(t, perms.Publish, "$JS.API.CONSUMER.CREATE."+stream+".>") + for _, p := range perms.Publish { + if SubjectsOverlap(p, "$KV."+GrantsBucket+".mesh-x") { + t.Errorf("the assigner may write %s through %q", GrantsBucket, p) + } + } + controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + has(t, controller.Publish, "$KV."+GrantsBucket+".>") +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index a6980a5d..01cf591d 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -92,6 +92,9 @@ type Seat struct { // DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind // and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench. DeclaredBy string + // PulledByKind says each accepted kind is a worker of its own on the seat's queue, filtered to that kind, which + // every holder pulls in the order Accepts lists them: the build seats' (novox/hq ADR 0525 §2, ADR 0536 §4). + PulledByKind bool } // A Principal is one user of the bus. Its permissions are derived from what it declares and @@ -222,32 +225,6 @@ type Principal struct { PasswordHash string } -// seatsTheControllerAsks are the roles the mesh's own flows submit work to. Named rather than -// derived from the seat set: the controller is not a module and declares no `uses`, so its side of a -// seat has to be stated, and a list is what makes "which roles does the mesh itself talk to" answerable. -// Both build roles while the handover runs (novox/hq ADR 0190): the controller asks whichever has a -// holder, and the retired one has one until build-agent replaces the builder. The second entry -// goes with the retired seat row. -var seatsTheControllerAsks = []string{"node-build-agent", "mesh-build-machine"} - -// TheControllersAsk says whether a message of stream, published on subject, is an ask the controller -// itself makes: one on the accept subject of a seat in seatsTheControllerAsks, kept in that seat's own -// work queue. Such an ask, given up on by the seat's worker, can be delivered again with the authority -// the controller already holds — the publish on that seat's accepts and the stream API to remove the -// original — and no other can (novox/hq issue 334, ADR 0264's consequences: no grant over the seats' -// queues). -func TheControllersAsk(stream, subject string) bool { - if isCheckAsk(stream, subject) { - return true - } - for _, seat := range seatsTheControllerAsks { - if stream == seatStreamName(seat) && strings.HasPrefix(subject, "mesh.seat."+seat+".accept.") { - return true - } - } - return false -} - // SeatVerb is one verb of one seat, on every machine holding it. type SeatVerb struct{ Seat, Verb string } @@ -424,22 +401,6 @@ var DegradableGrants = []DegradableGrant{ Reason: "refused: the controller closes a batch's window by a timer of its own for that batch, as the " + "window setting says (ADR 0407 decision 1, issue 973)", }, - { - Subject: "mesh.seat." + TheBuildSeat + ".check.>", - Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + - "first push, and a check asked before that is asked again by the next announcement of its head (ADR 0406 §3, " + - "issue 974)", - }, - { - Subject: "$KV." + CheckRunningBucket + ".>", - Reason: "refused: the controller asserts this bucket through the API and writes none of its keys — the build seat's " + - "holders do (ADR 0406 §3, issue 974)", - }, - { - Subject: "$KV." + PlansBuildingBucket + ".>", - Reason: "refused: the controller then says no plan builds, and no check is held while a delivery builds — a check run " + - "wrongly costs a delivery a place, a check held wrongly costs a pull request its turn (ADR 0483 clause 2, issue 1687)", - }, { Subject: "$KV." + DeclaredPackagesBucket + ".>", Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + @@ -460,6 +421,12 @@ var DegradableGrants = []DegradableGrant{ Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + "first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)", }, + { + Subject: "$KV." + GrantsBucket + ".>", + Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + + "first push, and writes a grant only after a send of the bus's user list, which comes after that (ADR 0528 " + + "clause 2, issue 1394)", + }, { Subject: "$KV." + RolloutsBucket + ".>", Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + @@ -659,24 +626,6 @@ func PermissionsFor(p Principal) (Permissions, error) { // like the catalogue does — which is why no holder needs to publish into anybody's inbox. // A node-scoped seat's work subject carries no node (novox/hq ADR 0190): the ask goes to // the role, and whichever machine holding it is idle takes it. - for _, seat := range seatsTheControllerAsks { - pub = append(pub, "mesh.seat."+seat+".accept.>") - // **And its checks, on the two queues of their own** (novox/hq ADR 0406 §3, issue 974): one subject a pull - // request on each, under `check`; the retired role's checks stay on its accepts. - if seat == TheBuildSeat { - pub = append(pub, "mesh.seat."+seat+".check.>") - } - // **And its holders' verbs, on every machine** (novox/hq ADR 0219): what a holder is - // building, kill it, pause it, resume it. The queue is the controller's to show and to - // change, and what one machine is doing with an ask it took only that machine can say. - pub = append(pub, "mesh.seat."+seat+".tool.>") - // **And its cancelled set** (novox/hq ADR 0219, issue 269): a cancel writes the ask's id - // there before it deletes the ask, and a write is a publish to the bucket's subject, which - // `$JS.API.>` does not cover — so every cancel timed out, refused by this list. - if hasCancelledSet(seat) { - pub = append(pub, "$KV."+CancelledSetName(seat)+".>") - } - } // **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own // facts under the seat it holds, because a role's events belong to the role and keep their // address while the holder is replaced. Named one by one rather than as a whole namespace: @@ -907,6 +856,15 @@ func PermissionsFor(p Principal) (Permissions, error) { sub = append(sub, BusConnectSubject, BusDisconnectSubject) pub = append(pub, BusConnzSubject) } + // The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a). + if p.Module == ViewModule { + pub = append(pub, BuildBacklogReads()...) + } + // The builder alone reads the build seats' streams by sequence, to put again at its start an ask a key names that no + // stream holds (issue 1910). + if p.Module == BuilderModule { + pub = append(pub, BuildAskReads()...) + } // 1. Its own namespace: it publishes its events there and serves its tools there. Nothing // else may publish into it, so an event's source is a fact the server enforces rather // than a claim in the body (design 29 §2). @@ -1013,19 +971,6 @@ func PermissionsFor(p Principal) (Permissions, error) { "$JS.API.CONSUMER.INFO."+stream+"."+worker, "$JS.API.CONSUMER.MSG.NEXT."+stream+"."+worker, "$JS.ACK."+stream+"."+worker+".>") - // And the checks' two workers, for the build role they are queued for (novox/hq ADR 0406 §3). - if s.Name == TheBuildSeat { - pub = append(pub, holderCheckGrants()...) - } - // **And whether an ask it took was cancelled** (novox/hq ADR 0219): one key of the - // seat's cancelled set, read directly by its id, so a holder that fetched an ask in the - // moment the controller cancelled it ends it instead of building it. Read, never written: - // the set is the controller's, and only the work queues the controller asks — and so may - // cancel from — have one. - if hasCancelledSet(s.Name) { - set := CancelledSetName(s.Name) - pub = append(pub, "$JS.API.DIRECT.GET.KV_"+set+".$KV."+set+".>") - } for _, a := range s.Accepts { sub = append(sub, seatSubject(s, "accept", a)) } diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index b0243280..62ec6620 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -481,10 +481,16 @@ func TestANodeSeatsWorkSubjectCarriesNoNode(t *testing.T) { hasNot(t, perms.Subscribe, "mesh.seat.node-build-agent.accept.build.anchor") // And its tools still carry the machine. has(t, perms.Subscribe, "mesh.seat.node-build-agent.tool.status.anchor") - // The controller asks the role, not a machine. + // The controller asks no build seat any more (R4g, ADR 0525 §10): no publish to any subject of the retired roles. controller, err := PermissionsFor(Principal{Kind: KindController}) if err != nil { t.Fatal(err) } - has(t, controller.Publish, "mesh.seat.node-build-agent.accept.>") + for _, seat := range []string{"node-build-agent", "mesh-build-machine"} { + for _, p := range controller.Publish { + if strings.HasPrefix(p, "mesh.seat."+seat+".") || strings.Contains(p, "SEAT_"+upperSnake(seat)) { + t.Errorf("the controller may still publish %s, a subject of the retired seat %s", p, seat) + } + } + } } diff --git a/internal/broker/no_build_queue_test.go b/internal/broker/no_build_queue_test.go new file mode 100644 index 00000000..ddd39843 --- /dev/null +++ b/internal/broker/no_build_queue_test.go @@ -0,0 +1,34 @@ +package broker + +import ( + "slices" + "strings" + "testing" +) + +// R4g (issue 1299, ADR 0525 clause 6): the controller declares no work-queue stream for builds or checks and no +// plans-building or checks-running bucket. The streams and buckets a bus already holds are left until a cleanup; none +// is asserted. +func TestTheControllerDeclaresNoBuildQueueAndNoBuildHold(t *testing.T) { + for _, s := range MeshStreams() { + if strings.HasPrefix(s.Name, "CHECKS_") || strings.HasPrefix(s.Name, "SEAT_") { + t.Errorf("the mesh's own streams hold %s, a queue of builds or checks", s.Name) + } + if s.Retention == RetentionWorkQueue && s.Name != "CONTROL" && s.Name != DeadLetterNoticesStream { + t.Errorf("%s is a work queue the controller declares", s.Name) + } + } + for _, c := range MeshConsumers() { + if strings.HasSuffix(c.Name, "_worker") { + t.Errorf("%s is a worker of a build or check queue", c.Name) + } + } + for _, name := range ControllerBuckets() { + if strings.Contains(name, "plans-building") || strings.Contains(name, "checks-running") { + t.Errorf("the controller's buckets hold %s", name) + } + } + if slices.Contains(ControllerBuckets(), "") { + t.Error("an unnamed bucket") + } +} diff --git a/internal/broker/plans_building_live_test.go b/internal/broker/plans_building_live_test.go deleted file mode 100644 index adaad82d..00000000 --- a/internal/broker/plans_building_live_test.go +++ /dev/null @@ -1,134 +0,0 @@ -//go:build integration - -package broker - -import ( - "context" - "errors" - "os" - "path/filepath" - "testing" - "time" - - "github.com/nats-io/nats-server/v2/server" - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - "golang.org/x/crypto/bcrypt" -) - -// **A holder of the build seat reads the plans building and the server refuses its write** (novox/hq ADR 0483 clause 2, issue -// 1687): on a real server whose users are composed as the controller composes them, the controller makes the bucket and puts a -// plan's key; the build agent's credential lists the keys and gets one, and its put of a key is refused with a permissions -// violation. It also reads the first waiting ask of a check queue by a direct get, which takes nothing from the queue. -func TestNatsTheBuildSeatsHoldersReadThePlansBuildingAndTheServerRefusesTheirWrite(t *testing.T) { // 1687 - hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost) - if err != nil { - t.Fatal(err) - } - seat := Seat{Name: TheBuildSeat, Scope: "node", Accepts: []string{"build"}, Emits: []string{"built", "started"}} - users, err := Users(Records{Nodes: []string{"alpha"}, Assigned: map[string][]Declared{ - "alpha": {{Module: RuntimeModule}, {Module: "build-agent", Holds: []Seat{seat}}}, - }}) - if err != nil { - t.Fatal(err) - } - for i := range users { - users[i].PasswordHash = string(hash) - } - accounts, err := ComposeAccounts(users) - if err != nil { - t.Fatal(err) - } - conf := filepath.Join(t.TempDir(), "bus.conf") - if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+ - `"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil { - t.Fatal(err) - } - opts, err := server.ProcessConfigFile(conf) - if err != nil { - t.Fatalf("the composed accounts do not parse: %v", err) - } - opts.NoLog, opts.NoSigs = true, true - s, err := server.NewServer(opts) - if err != nil { - t.Fatal(err) - } - go s.Start() - if !s.ReadyForConnections(10 * time.Second) { - t.Fatal("the bus did not come up") - } - t.Cleanup(s.Shutdown) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - connect := func(user string) (*nats.Conn, chan error) { - t.Helper() - refused := make(chan error, 8) - nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user), - nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refused <- err })) - if err != nil { - t.Fatalf("%s cannot connect: %v", user, err) - } - t.Cleanup(nc.Close) - return nc, refused - } - controller, _ := connect(ControllerName) - cj := OnConn(controller) - if err := AssertMeshStreams(cj); err != nil { - t.Fatalf("the controller cannot make the streams: %v", err) - } - if err := cj.EnsureControllerBuckets(); err != nil { - t.Fatalf("the controller cannot make its buckets: %v", err) - } - cjs, _ := jetstream.New(controller) - ckv, err := cjs.KeyValue(ctx, PlansBuildingBucket) - if err != nil { - t.Fatal(err) - } - if _, err := ckv.Put(ctx, "plan-1", []byte(`{"plan":"plan-1"}`)); err != nil { - t.Fatalf("the controller cannot put a plan's key: %v", err) - } - - holder, refused := connect("alpha.build-agent") - hjs, err := jetstream.New(holder) - if err != nil { - t.Fatal(err) - } - kv, err := hjs.KeyValue(ctx, PlansBuildingBucket) - if err != nil { - t.Fatalf("a holder of the build seat cannot bind %s: %v", PlansBuildingBucket, err) - } - keys, err := kv.Keys(ctx) - if err != nil || len(keys) != 1 || keys[0] != "plan-1" { - t.Fatalf("the holder lists %v (%v), want plan-1", keys, err) - } - if e, err := kv.Get(ctx, "plan-1"); err != nil || e == nil { - t.Fatalf("the holder cannot get the key: %v", err) - } - // Its write is refused by the server. - if err := holder.Publish("$KV."+PlansBuildingBucket+".plan-2", []byte(`{}`)); err != nil { - t.Fatal(err) - } - _ = holder.Flush() - select { - case err := <-refused: - if !errors.Is(err, nats.ErrPermissionViolation) { - t.Errorf("the server said %v, want a permissions violation", err) - } - case <-time.After(3 * time.Second): - t.Error("the server did not refuse the holder's put of a plan's key") - } - if _, err := ckv.Get(ctx, "plan-2"); !errors.Is(err, jetstream.ErrKeyNotFound) { - t.Errorf("the refused put left a key: %v", err) - } - - // And the direct get of a check queue's first waiting ask, which is granted. - reply, err := holder.RequestWithContext(ctx, "$JS.API.DIRECT.GET."+ChecksOtherStream, []byte(`{"seq":1,"next_by_subj":"`+ - CheckSubjectPrefix(TheBuildSeat, false)+`>"}`)) - if err != nil { - t.Fatalf("the holder's direct get of %s was not answered: %v", ChecksOtherStream, err) - } - if reply.Header.Get("Status") != "404" { - t.Errorf("an empty queue answered %q, want 404", reply.Header.Get("Status")) - } -} diff --git a/internal/broker/plans_building_mirror_test.go b/internal/broker/plans_building_mirror_test.go deleted file mode 100644 index 9b57f4d3..00000000 --- a/internal/broker/plans_building_mirror_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package broker - -import ( - "slices" - "testing" -) - -// Issue 1687 (ADR 0483 clause 2): a module whose manifest reads mesh-controller.plans-building may name it in `reads`, and -// reads the bucket and writes none; the controller writes it, so it is no mirror written by another. -func TestAModuleReadingPlansBuildingReadsAndWritesNone(t *testing.T) { - if !slices.Contains(controllerReadableStates(), "plans-building") || BucketName(ControllerSeat, PlansBuilding) != PlansBuildingBucket { - t.Fatalf("a module cannot name %s in `reads`: %v", PlansBuildingBucket, controllerReadableStates()) - } - if _, other := MirrorsWrittenByAnother[PlansBuilding]; other { - t.Error("the controller writes the plans building itself") - } - perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", PasswordHash: "x", - Reads: []string{"mesh-controller.plans-building"}}) - if err != nil { - t.Fatal(err) - } - stream := "KV_" + PlansBuildingBucket - has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream) - has(t, perms.Publish, "$JS.API.DIRECT.GET."+stream+".>") - for _, p := range perms.Publish { - if SubjectsOverlap(p, "$KV."+PlansBuildingBucket+".plan-1") { - t.Errorf("the reading module may write %s through %q", PlansBuildingBucket, p) - } - } -} diff --git a/internal/broker/seattraffic.go b/internal/broker/seattraffic.go index ff797e1f..eea8e19c 100644 --- a/internal/broker/seattraffic.go +++ b/internal/broker/seattraffic.go @@ -1,6 +1,9 @@ package broker -import "sort" +import ( + "fmt" + "sort" +) // What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR // 0259 §3, to-be 46 §10). @@ -24,6 +27,10 @@ import "sort" // And one read: **a holder's records**, a bucket the seat names, read by each user under its own name only // (`$KV...>`), so an asker reads the state of its own asks and no other asker's. +// BuildAskMaxDeliver bounds the deliveries of a build seat's ask (issue 1907): mesh-sdk delivery.AskMaxDeliver, written +// here as the broker mirrors the catalogue rather than importing it. +const BuildAskMaxDeliver = 5 + // KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded. var KindedBenches = map[string]bool{"channel": true, "intake": true} @@ -61,7 +68,14 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { continue } } - if len(s.Accepts) > 0 { + if len(s.Accepts) > 0 && s.PulledByKind { + // One worker per accepted kind, which every holder pulls in the order of Accepts (ADR 0525 §4, ADR 0536 §4): + // `SEAT___worker`, filtered to `accept.` (mesh-sdk delivery.AskWorker). + for _, a := range s.Accepts { + t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: WorkerName(s.Name, a), + Filter: "mesh.seat." + s.Name + ".accept." + a}) + } + } else if len(s.Accepts) > 0 { stream := seatStreamName(s.Name) filter := "mesh.seat." + s.Name + ".accept.>" if kind != "" { @@ -156,7 +170,7 @@ func seatTrafficGrants(t SeatTraffic) (pub, sub []string) { // isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is // composed exactly as before them. func (s Seat) isNewTraffic() bool { - return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 + return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 || s.PulledByKind } // plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by @@ -183,10 +197,10 @@ func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { consumers := map[string]Consumer{} add := func(module string, holds []Seat) { for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers { - seat := "" + seat, byKind := "", false for _, s := range holds { if seatStreamName(s.Name) == w.Stream { - seat = s.Name + seat, byKind = s.Name, s.PulledByKind } } streams[w.Stream] = Stream{ @@ -196,7 +210,7 @@ func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { MaxAge: 7 * 24 * 60 * 60, Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does", } - consumers[w.Consumer] = Consumer{ + c := Consumer{ Name: w.Consumer, Stream: w.Stream, Filters: []string{w.Filter}, @@ -207,6 +221,15 @@ func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " + "recorded it, so a crash redelivers rather than loses", } + if byKind { + // A build seat's ask is offered a bounded number of times (issue 1907): the holder that takes it past + // mesh-sdk delivery.AskRuns ends it failed saying it is poison, and the bound stops a run that kills + // every node it reaches even when that holder dies too. + c.MaxDeliver = BuildAskMaxDeliver + c.Why = module + " holds " + seat + "; each holder pulls an ask when it has a free slot, and an ask that " + + "no run ends is offered at most " + fmt.Sprint(BuildAskMaxDeliver) + " times, its step failed as poison" + } + consumers[w.Consumer] = c } } for _, p := range users { diff --git a/internal/broker/seattraffic_test.go b/internal/broker/seattraffic_test.go index 08110088..cfe8218b 100644 --- a/internal/broker/seattraffic_test.go +++ b/internal/broker/seattraffic_test.go @@ -388,3 +388,53 @@ func TestVerifiedSenderNeedsTheChannelsAndTheRoutersMachinesRootFree(t *testing. t.Error("no router placed, and verified-sender kept") } } + +func buildSeat() Seat { + return Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, + Serves: []string{"current", "pause", "resume"}, PulledByKind: true} +} + +// 1887/A6, ADR 0525 §2: the builder, which uses the build seat, may put an ask on each kind; a session's account may +// not; the seat's holder pulls a worker per kind and no catch-all worker is made beside them. +func TestTheBuilderMayAskABuildSeatAndASessionMayNot(t *testing.T) { + builder := perms(t, Principal{Kind: KindModule, Node: "novox", Module: "mesh-delivery-builder", Uses: []Seat{buildSeat()}}) + for _, kind := range []string{"delivery", "check", "recheck"} { + if s := "mesh.seat.node-build-go.accept." + kind; !allowed(builder.Publish, s) { + t.Errorf("the builder may not publish %s", s) + } + } + session := perms(t, Principal{Kind: KindPerson, Module: "jochen", Invokes: []string{"mesh-controller.status"}}) + if allowed(session.Publish, "mesh.seat.node-build-go.accept.delivery") { + t.Error("a session's account may put an ask") + } + holds := []Seat{buildSeat()} + traffic := SeatTrafficOf("mesh-build-go", holds, nil, nil) + var workers []string + for _, w := range traffic.Workers { + workers = append(workers, w.Consumer+" "+w.Filter) + } + // Sorted: the order a holder pulls them in is its own (mesh-sdk delivery.AskKinds). + want := "SEAT_NODE_BUILD_GO_CHECK_worker mesh.seat.node-build-go.accept.check," + + "SEAT_NODE_BUILD_GO_DELIVERY_worker mesh.seat.node-build-go.accept.delivery," + + "SEAT_NODE_BUILD_GO_RECHECK_worker mesh.seat.node-build-go.accept.recheck" + if strings.Join(workers, ",") != want { + t.Errorf("workers = %v, want one per kind", workers) + } + pub, _ := seatTrafficGrants(traffic) + if !allowed(pub, "$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker") { + t.Error("the holder may not pull the check worker") + } + _, consumers := SeatTrafficObjects([]Principal{{Kind: KindModule, Module: "mesh-build-go", Holds: holds}}) + if len(consumers) != 3 { + t.Errorf("consumers = %+v, want the three kinds' workers", consumers) + } + // 1907: a build ask is offered a bounded number of times, past the holder's poison count (mesh-sdk AskRuns 3). + for _, c := range consumers { + if c.MaxDeliver != BuildAskMaxDeliver || BuildAskMaxDeliver <= 3 { + t.Errorf("%s offers an ask %d times, want %d", c.Name, c.MaxDeliver, BuildAskMaxDeliver) + } + } + if _, made := HolderConsumerFor("shanks", "mesh-build-go", DeclaredSeat{Name: "node-build-go", Accepts: buildSeat().Accepts, PulledByKind: true}); made { + t.Error("a catch-all worker would overlap the kinds' workers on the work queue") + } +} diff --git a/internal/broker/stream_names_agreement_test.go b/internal/broker/stream_names_agreement_test.go index c57e0d67..9990f2d0 100644 --- a/internal/broker/stream_names_agreement_test.go +++ b/internal/broker/stream_names_agreement_test.go @@ -9,7 +9,12 @@ import ( // 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. +// +// **The names of streams the mesh no longer asserts stay reserved** (R4g, ADR 0525 clause 6): the pull requests' two check +// queues are left on the bus until a cleanup removes them, and no module may take their names meanwhile. So the catalogue +// reserves every name of the broker's own, and may reserve these retired ones beside them. func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) { + retired := []string{"CHECKS_OTHER", "CHECKS_READY"} var own []string for _, s := range MeshStreams() { own = append(own, s.Name) @@ -17,7 +22,24 @@ func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) { 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) + var extra []string + for _, name := range reserved { + if !slices.Contains(own, name) { + extra = append(extra, name) + } + } + var missing []string + for _, name := range own { + if !slices.Contains(reserved, name) { + missing = append(missing, name) + } + } + for _, name := range extra { + if !slices.Contains(retired, name) { + t.Errorf("the catalogue reserves %s, which the broker neither asserts nor names retired", name) + } + } + if len(missing) > 0 { + t.Fatalf("the broker's own streams %v are not reserved by the catalogue (it reserves %v)", missing, reserved) } } diff --git a/internal/broker/streams.go b/internal/broker/streams.go index c261f1a8..f694c83f 100644 --- a/internal/broker/streams.go +++ b/internal/broker/streams.go @@ -154,7 +154,7 @@ func OriginalOfAgain(subject string) (string, bool) { // every tool invocation in the mesh, and a tool call must never be persisted (design 25 §3 keeps // tools on core NATS, where a lost call is a timeout the caller already handles). func MeshStreams() []Stream { - return append([]Stream{ + return []Stream{ { Name: "CONTROL", // A build's outcome is no longer here: it is the build-machine seat's own event, so one @@ -228,7 +228,7 @@ func MeshStreams() []Stream { "failed with the reason (novox/hq ADR 0349 §4): what a walk's end and a gate wait for (ADR 0362, " + "ADR 0349 §15), read by subject; a week, as the events are", }, - }, CheckStreams()...) + } } // ProvAppliedStream keeps what each machine's provisioner client last said of each module's files, newest per @@ -495,7 +495,7 @@ const EventsStream = "EVENTS" // held through a store restart — the exact message the stream exists to protect — some minutes // before the controller had finished deciding about it. func MeshConsumers() []Consumer { - return append(ControllerConsumers(), CheckWorkers()...) + return ControllerConsumers() } // ControllerConsumers are the consumers the controller's own event loop takes from: MeshConsumers without the build diff --git a/internal/broker/streams_test.go b/internal/broker/streams_test.go index 68831f41..7a52d422 100644 --- a/internal/broker/streams_test.go +++ b/internal/broker/streams_test.go @@ -134,9 +134,6 @@ func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) { // What each machine's provisioner client last said of each module's files (design 57 P6): the newest // word per module, what a walk's end reads. "PROV_APPLIED": RetentionLastPerSubject, - // The checks of pull requests, in two work queues of their own (novox/hq ADR 0406 §3, issue 974). - "CHECKS_READY": RetentionWorkQueue, - "CHECKS_OTHER": RetentionWorkQueue, } got := map[string]Retention{} for _, s := range MeshStreams() { diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index dfc1d983..5d4ce16e 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -29,7 +29,7 @@ accounts { ] users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_plans-building.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 73dd8369..b904ed1e 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -43,14 +43,6 @@ func ownMachine(p Principal, pattern string) bool { return p.Kind == KindNode && len(tokens) > 2 && tokens[2] == p.Node } -// holdsTheBuildSeat is a principal holding the build seat, or the controller, which asserts the bucket the holders write. -func holdsTheBuildSeat(p Principal, _ string) bool { - if p.Kind == KindController { - return true - } - return holdsSeatOf(p, "mesh.seat."+TheBuildSeat+".x") -} - // holdsSeatOf is a principal holding the seat a `mesh.seat..…` pattern names: its module's own // principal, or the node tools carrying a module that holds it (ADR 0175, the runtime is its modules). func holdsSeatOf(p Principal, pattern string) bool { @@ -184,19 +176,6 @@ var WritersTable = []WriterRow{ Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"}, Writes: holdsSeatOf, Shared: "every machine holding the build seat answers the asks it took; each outcome names its ask"}, - {State: "a pull request's check asked", Writer: "controller", - KeptIn: "the work queues " + ChecksReadyStream + " and " + ChecksOtherStream + ", one subject a pull request, the newest kept", - Others: "the build seat's holders take it", - Subjects: []string{CheckSubjectPrefix(TheBuildSeat, true) + ">", CheckSubjectPrefix(TheBuildSeat, false) + ">"}, - Writes: isController}, - {State: "a pull request's check running", Writer: "the build seat's holders — several by design, each the key of the check it runs", - KeptIn: "key-value " + CheckRunningBucket + ", aged out when not renewed", Others: "the build seat's holders read it to drop a duplicate request", - Subjects: kvOf(CheckRunningBucket), Writes: holdsTheBuildSeat, - Shared: "every machine holding the build seat puts the key of the check it runs and deletes it when the check ends"}, - {State: "the plans-building mirror", Writer: "controller (lease holder), at the first build's ask and at each refresh", - KeptIn: "key-value " + PlansBuildingBucket + ", aged out when not refreshed", - Others: "the build seat's holders read it and take no new check while a key is there (ADR 0483)", - Subjects: kvOf(PlansBuildingBucket), Writes: isController}, // What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module, kept by // the controller. {State: "a module's states announced", Writer: "the module itself, from its code, under its own name", @@ -242,6 +221,10 @@ var WritersTable = []WriterRow{ {State: "the rollouts of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision", KeptIn: "key-value " + RolloutsBucket + ", sixty-four revisions of each key, none removed", Others: "read through `reads` as mesh-controller." + ChainRollouts, Subjects: kvOf(RolloutsBucket), Writes: isController}, + // The grants issued (novox/hq ADR 0528 clause 2, issue 1394): the controller's, read by the assigner for its sixth rule. + {State: "the grants issued", Writer: "controller (lease holder), once the bus's user list carrying a module's manifest at a fingerprint was sent", + KeptIn: "key-value " + GrantsBucket + ", one key a module's fingerprint, none removed", + Others: "read through `reads` as mesh-controller." + ControllerGrants, Subjects: kvOf(GrantsBucket), Writes: isController}, // What each machine has of each module (novox/hq ADR 0429 §1, design 47): written by that machine's node-engine under // its own node's keys, never by the controller; the view and the watchman read it. {State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `.>`", diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index aed01b84..f1e55584 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -27,10 +27,6 @@ var designRows = []string{ "the healers' acts and their brake", "stream definitions and bus permissions", "builds and their outcomes", - "a pull request's check asked", - "a pull request's check running", - // The plans in their build phase, a bucket the build seat's holders read (ADR 0483 clause 2, issue 1687). - "the plans-building mirror", // What each module keeps and who may read it, said by the module and kept by the controller (ADR 0495, issue 1790). "a module's states announced", "the states announced", @@ -46,6 +42,8 @@ var designRows = []string{ // The chain beside the assignments, the record itself and no mirror of one (ADR 0408, design 47, issue 1089). "the builds of the chain", "the rollouts of the chain", + // The grants issued, which the assigner's sixth rule reads (ADR 0528 clause 2, issue 1394). + "the grants issued", // What each machine has of each module, written by its node-engine under its own keys (ADR 0429 §1, issue 1136). "a machine's have", // The contents of a declaration's large files, in the bus's object bucket (ADR 0486 §2, issue 1697). diff --git a/internal/builder/archive_symlink_test.go b/internal/builder/archive_symlink_test.go deleted file mode 100644 index a6c2eae4..00000000 --- a/internal/builder/archive_symlink_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package builder - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// A link as a parent of an archive's `from` must not lead the build out of the module: the repository -// is somebody else's, and a build that followed it would pack, register and serve any directory the -// build account can read (issue 1609). -func TestAnArchiveWhoseFromGoesThroughALinkOutOfTheModuleIsRefused(t *testing.T) { - outside := t.TempDir() - if err := os.MkdirAll(filepath.Join(outside, "x"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(outside, "x", "secret"), []byte("s"), 0o644); err != nil { - t.Fatal(err) - } - tree := t.TempDir() - if err := os.Symlink(outside, filepath.Join(tree, "link")); err != nil { - t.Fatal(err) - } - r := &recorded{} - a := catalogue.Artifact{Name: "all", Kind: catalogue.ArtifactArchive, From: "link/x"} - _, err := one(context.Background(), nil, r, "m", tree, t.TempDir(), "c", "", a, nil, nil, "", Npmrc{}, nil, - nil, func(string, string, ...any) {}) - if err == nil { - t.Fatal("an archive was packed through a link out of the module") - } - realOutside, _ := filepath.EvalSymlinks(filepath.Join(outside, "x")) - realTree, _ := filepath.EvalSymlinks(tree) - for _, want := range []string{realOutside, realTree, "link/x"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal %q does not name %q", err, want) - } - } - if len(r.archives) != 0 { - t.Errorf("a refused archive left %d published", len(r.archives)) - } -} - -// A link that stays inside the module is still followed, and its archive keeps its digest. -func TestAnArchiveWhoseFromGoesThroughALinkInsideTheModuleIsPacked(t *testing.T) { - tree := t.TempDir() - if err := os.MkdirAll(filepath.Join(tree, "real", "x"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(tree, "real", "x", "f"), []byte("f"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Symlink("real", filepath.Join(tree, "link")); err != nil { - t.Fatal(err) - } - r := &recorded{} - a := catalogue.Artifact{Name: "all", Kind: catalogue.ArtifactArchive, From: "link/x"} - if _, err := one(context.Background(), nil, r, "m", tree, t.TempDir(), "c", "", a, nil, nil, "", Npmrc{}, nil, - nil, func(string, string, ...any) {}); err != nil { - t.Fatal(err) - } -} - -// A module directory that is itself a link out of the fetched tree is refused, naming both places. -func TestAModuleDirectoryThatIsALinkOutOfTheRepositoryIsRefused(t *testing.T) { - outside := t.TempDir() - if err := os.WriteFile(filepath.Join(outside, ManifestName), []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - tree := t.TempDir() - if err := os.MkdirAll(filepath.Join(tree, "modules"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, filepath.Join(tree, "modules", "evil")); err != nil { - t.Fatal(err) - } - _, err := inside(tree, "modules/evil") - if err == nil { - t.Fatal("a module directory behind a link out of the repository was accepted") - } - realOutside, _ := filepath.EvalSymlinks(outside) - realTree, _ := filepath.EvalSymlinks(tree) - for _, want := range []string{realOutside, realTree, "modules/evil"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal %q does not name %q", err, want) - } - } - // A link that stays inside the repository, and a module directory not there, are left as they were. - if err := os.MkdirAll(filepath.Join(tree, "real"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink("real", filepath.Join(tree, "modules", "fine")); err != nil { - t.Fatal(err) - } - if _, err := inside(tree, "modules/fine"); err != nil { - t.Errorf("a link inside the repository was refused: %v", err) - } - if _, err := inside(tree, "modules/none"); err != nil { - t.Errorf("an absent module directory was refused here: %v", err) - } -} - -// A repository that commits `.mesh-build` as a link must not make a bundle's build write into, or pack, -// a directory outside the module (issue 1609): the compile, the bundling and the pack each refuse it. -func TestABundleWhoseOutputDirectoryIsALinkOutOfTheModuleIsRefused(t *testing.T) { - outside := t.TempDir() - if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("s"), 0o644); err != nil { - t.Fatal(err) - } - tree := t.TempDir() - if err := os.Symlink(outside, filepath.Join(tree, ".mesh-build")); err != nil { - t.Fatal(err) - } - chain, err := ToolchainFor("go") - if err != nil { - t.Fatal(err) - } - a := catalogue.Artifact{Name: "b", Kind: catalogue.ArtifactBundle, Language: "go", From: "cmd/b"} - ran := false - run := func(context.Context, string, string, ...string) (string, error) { ran = true; return "", nil } - realOutside, _ := filepath.EvalSymlinks(outside) - realTree, _ := filepath.EvalSymlinks(tree) - - check := func(what string, err error) { - t.Helper() - if err == nil { - t.Fatalf("%s went through a link out of the module", what) - } - for _, want := range []string{realOutside, realTree} { - if !strings.Contains(err.Error(), want) { - t.Errorf("%s: the refusal %q does not name %q", what, err, want) - } - } - } - _, err = compile(context.Background(), run, tree, chain, "base", a) - check("compile", err) - _, err = bundled(context.Background(), run, tree, chain, "base", a, nil) - check("bundled", err) - check("the pack", outsideTheModule(tree, filepath.Join(tree, Out(a.Name)))) - if ran { - t.Error("a command ran for a refused bundle") - } - if entries, _ := os.ReadDir(outside); len(entries) != 1 { - t.Errorf("a refused bundle wrote into the directory outside: %d entries", len(entries)) - } -} diff --git a/internal/builder/binary_name_test.go b/internal/builder/binary_name_test.go deleted file mode 100644 index efefbe1f..00000000 --- a/internal/builder/binary_name_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package builder - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The name a machine runs a binary by is not always the name of the package that built it. The host's -// command is cmd/mesh-host and every machine runs it as nox-mesh-host — the path it is installed at, -// the name in its unit, and the name its launcher looks for inside a delivered version. -// -// A bundle carrying the package's name was delivered to a machine correctly, reported "created … 1 -// file(s)", and was invisible to the launcher (novox/hq 04-ISSUES/142). Found by reading the delivered -// directory rather than by trusting the line that said it worked. - -func TestACompiledArtifactNamesTheBinaryAMachineWillRun(t *testing.T) { - got := binaryName(catalogue.Artifact{ - Name: "host-arch", From: "cmd/mesh-host", Binary: "nox-mesh-host", - }) - if got != "nox-mesh-host" { - t.Fatalf("the binary is named %q, and the launcher looks for nox-mesh-host", got) - } -} - -func TestSayingNothingKeepsWhatTheCompilerWouldHaveChosen(t *testing.T) { - // go build names its output after the package, so an artifact that says nothing gets the same - // thing it got before this existed. - if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "cmd/mesh-host"}); got != "mesh-host" { - t.Fatalf("an artifact naming no binary produced %q", got) - } - if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "./cmd/agent/"}); got != "agent" { - t.Fatalf("a from with slashes produced %q", got) - } -} - -func TestABundleBuiltFromTheModuleRootFallsBackToItsArtifactName(t *testing.T) { - // A single-command repository names no package, and `go build -o ` would then write a file - // named after the module directory — which is not something the manifest states. The artifact's - // own name is what the manifest does state. - if got := binaryName(catalogue.Artifact{Name: "tool"}); got != "tool" { - t.Fatalf("a bundle built from the root produced %q", got) - } -} diff --git a/internal/builder/build_source_test.go b/internal/builder/build_source_test.go deleted file mode 100644 index c3433dcc..00000000 --- a/internal/builder/build_source_test.go +++ /dev/null @@ -1,226 +0,0 @@ -//go:build integration - -package builder - -import ( - "context" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// What a build says it was made from, as files (novox/hq ADR 0267), and what a build compiling a Go -// program is handed. - -// onTrunk answers the trunk's questions as a clone of a commit on main would, or off it. -type onTrunk struct { - *recorded - off bool - // behind is a commit on the trunk that is not its head: an older commit built by hand. - behind bool -} - -func (o onTrunk) run(ctx context.Context, dir, name string, args ...string) (string, error) { - if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" { - return "origin/main\n", nil - } - if name == "git" && len(args) > 1 && args[0] == "rev-parse" && args[1] == "origin/main" && o.behind { - return "feedfacefeedfacefeedfacefeedfacefeedface\n", nil - } - if name == "git" && len(args) > 0 && args[0] == "merge-base" && o.off { - return "", os.ErrNotExist - } - return compiling{o.recorded}.run(ctx, dir, name, args...) -} - -// aSharedRepository is a repository holding two programs that share a package, as the controller's does. -func aSharedRepository(readme, shared string) map[string]string { - return map[string]string{ - "go.mod": "module example.com/ctl\n\ngo 1.22\n", - "go.sum": "", - "README.md": readme, - "cmd/ctl/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n", - "proxy/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n", - "internal/shared/s.go": "package shared\n\nconst S = " + shared + "\n", - "internal/only/o.go": "package only\n", - } -} - -const aProxy = `{"module":"route-proxy","version":"1", - "build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile","compiles":"proxy", - "context":{"repository":"https://forge.invalid/ctl.git","ref":"main"}}, - {"name":"trust","kind":"upstream","from":"alpine@sha256:` + "3333333333333333333333333333333333333333333333333333333333333333" + `"}]}}` - -func buildTheProxy(t *testing.T, context_ map[string]string, off bool, behind ...bool) (Result, *recorded, string) { - t.Helper() - r := &recorded{ - contents: map[string]string{"modules/route-proxy/" + ManifestName: aProxy, "modules/route-proxy/Dockerfile": "FROM scratch\nCOPY . .\n", "modules/route-proxy/README.md": "x"}, - secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": context_}, - } - workspace := t.TempDir() - got, err := Build(context.Background(), onTrunk{r, off, len(behind) > 0 && behind[0]}.run, r, - "https://forge.invalid/catalogue.git", "modules/route-proxy", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - return got, r, workspace -} - -func TestAnImageCompilingGoIsHandedItsBuildSourceAndSaysIt(t *testing.T) { - got, r, workspace := buildTheProxy(t, aSharedRepository("one", "1"), false) - - // Built in the narrowed tree, which holds the program's closure and nothing else. - at := "" - for i, line := range r.ran { - if strings.HasPrefix(line, "docker build ") { - at = r.dirs[i] - } - } - if filepath.Base(at) != "narrow-server" { - t.Fatalf("docker build ran in %q, not the narrowed build source", at) - } - for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true, - "cmd/ctl/main.go": false, "internal/only/o.go": false, "README.md": false} { - held := slices.Contains(r.builtFrom, file) - if held != want { - t.Errorf("%s handed to the recipe: %v, wanted %v", file, held, want) - } - } - if _, err := os.Stat(filepath.Join(workspace, "narrow-server")); err == nil { - t.Error("the narrowed build source was left in the workspace") - } - - // Said per repository: its own, the manifest and the recipe; the context's, the closure. - if len(got.Sources) != 2 { - t.Fatalf("sources %+v", got.Sources) - } - own, ctx := got.Sources[0], got.Sources[1] - if own.Repository != "" || !slices.Equal(own.Paths, []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}) { - t.Errorf("its own build source: %+v", own) - } - if ctx.Repository != "https://forge.invalid/ctl.git" || ctx.Ref != "main" { - t.Errorf("the context's build source names %q at %q", ctx.Repository, ctx.Ref) - } - for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true, - "cmd/ctl/main.go": false, "README.md": false} { - if SourceHolds(ctx.Paths, file) != want { - t.Errorf("the context's build source holds %s: %v, wanted %v (%v)", file, !want, want, ctx.Paths) - } - } - - // **The fingerprint is over the build source** (rule 5): a change outside it is one build, inside it another. - readme, _, _ := buildTheProxy(t, aSharedRepository("two", "1"), false) - if readme.Source != got.Source { - t.Errorf("a README of the context changed the fingerprint: %s %s", got.Source, readme.Source) - } - shared, _, _ := buildTheProxy(t, aSharedRepository("one", "2"), false) - if shared.Source == got.Source { - t.Error("a change to the program's closure kept its fingerprint") - } -} - -// A build off the trunk, or of a trunk commit that is not its head, says no build source: the planner maps -// a merge onto the trunk head's, and an older commit's closure lacks what was imported since. -func TestABuildOffTheTrunksHeadSaysNoBuildSource(t *testing.T) { - got, _, _ := buildTheProxy(t, aSharedRepository("one", "1"), true) - if len(got.Sources) != 0 { - t.Fatalf("a build off the trunk said %+v", got.Sources) - } - got, _, _ = buildTheProxy(t, aSharedRepository("one", "1"), false, true) - if len(got.Sources) != 0 { - t.Fatalf("a build of an older trunk commit said %+v", got.Sources) - } -} - -// An archive of the module's whole directory holds every file of it. -func TestAnArchiveOfTheWholeDirectoryHoldsIt(t *testing.T) { - manifest := `{"module":"look","version":"1","build":{"artifacts":[{"name":"all","kind":"archive","from":"."}]}, - "resources":[{"id":"files","type":"archive","path":"/opt/look","artifact":"all"}]}` - r := &recorded{contents: map[string]string{"modules/look/" + ManifestName: manifest, "modules/look/a/b.css": "x"}} - got, err := Build(context.Background(), onTrunk{recorded: r}.run, r, - "https://forge.invalid/catalogue.git", "modules/look", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if len(got.Sources) != 1 || !SourceHolds(got.Sources[0].Paths, "modules/look/a/b.css") || - SourceHolds(got.Sources[0].Paths, "modules/other/x") { - t.Fatalf("sources %+v", got.Sources) - } -} - -// A recipe reading past its build source fails, naming what it could not find — in the real docker build; -// here, the file is simply not in the tree it is handed, which is what makes that so. -func TestAnImageCompilingANonexistentPackageFails(t *testing.T) { - r := &recorded{ - contents: map[string]string{ManifestName: strings.Replace(aProxy, `"compiles":"proxy"`, `"compiles":"nowhere"`, 1), - "Dockerfile": "FROM scratch\n"}, - secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": aSharedRepository("one", "1")}, - } - _, err := Build(context.Background(), onTrunk{recorded: r}.run, r, - "https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err == nil || !strings.Contains(err.Error(), "nowhere") { - t.Fatalf("a package that is not there built: %v", err) - } -} - -// A Go bundle of a module built from its repository's root says its import closure, and the manifest; -// an image that compiles nothing it was told of leaves the module's source whole, and says none. -func TestAGoBundleSaysItsClosureAndAnUntoldImageNothing(t *testing.T) { - files := aSharedRepository("one", "1") - manifest := `{"module":"ctl","version":"1","build":{"artifacts":[ - {"name":"controller","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]}, - "resources":[{"id":"controller","type":"process","name":"ctl","artifact":"controller","run":["./ctl"]}]}` - r := &recorded{contents: map[string]string{ManifestName: manifest}} - for k, v := range files { - r.contents[k] = v - } - held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)} - got, err := Build(context.Background(), onTrunk{recorded: r}.run, r, - "https://forge.invalid/ctl.git", "", "", t.TempDir(), held, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if len(got.Sources) != 1 || got.Sources[0].Repository != "" { - t.Fatalf("sources %+v", got.Sources) - } - for file, want := range map[string]bool{"cmd/ctl/main.go": true, "internal/shared/s.go": true, ManifestName: true, - "go.sum": true, "proxy/main.go": false, "README.md": false, "internal/only/o.go": false} { - if SourceHolds(got.Sources[0].Paths, file) != want { - t.Errorf("%s: held %v, wanted %v (%v)", file, !want, want, got.Sources[0].Paths) - } - } - - untold := `{"module":"ctl","version":"1","build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile"}]}}` - r = &recorded{contents: map[string]string{ManifestName: untold, "Dockerfile": "FROM scratch\n"}} - got, err = Build(context.Background(), onTrunk{recorded: r}.run, r, - "https://forge.invalid/ctl.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if len(got.Sources) != 0 { - t.Fatalf("an image compiling nothing it was told of said a build source: %+v", got.Sources) - } -} - -// 1215/AC1 (compiled here, run by the merge check; the unit level has 1215/AC1 over the same inputs): a build's -// result carries every input of its fingerprint, and the SDK function over them gives it again. -func TestABuildSaysTheInputsItsFingerprintWasComputedFrom(t *testing.T) { - got, _, _ := buildTheProxy(t, aSharedRepository("one", "1"), false) - if got.SourceInputs == nil { - t.Fatal("the result carries no source inputs") - } - in := *got.SourceInputs - if in.Module != "route-proxy" || in.Tree == "" || in.Contexts["server"] == "" { - t.Errorf("the inputs miss the module, its tree or the server's context: %+v", in) - } - fp, none := delivery.SourceFingerprint(in) - if fp == "" || none != "" || fp != got.Source { - t.Errorf("the inputs give %q (%q); the build recorded %q", fp, none, got.Source) - } -} diff --git a/internal/builder/builder.go b/internal/builder/builder.go deleted file mode 100644 index df14eeaa..00000000 --- a/internal/builder/builder.go +++ /dev/null @@ -1,2160 +0,0 @@ -package builder - -import ( - "archive/tar" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "os" - "os/exec" - "path" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-controller/internal/buildreads" -) - -// Turning a repository into artifacts the mesh can pin. -// -// **This runs on a node, not in the control plane.** Building needs a container runtime and a -// working tree, and the control plane deliberately cannot run commands on a machine — what it may -// send is bounded by the declaration language (novox/hq ADR 0005), and "run this build" is not in -// it. So the builder is something a node runs *as a module*, given work over the broker like -// anything else, and this package is what it does when it gets some. -// -// The alternative — the control plane holding a docker socket — would make it the one component -// that can do anything on a machine, which is the property the whole design is arranged to avoid. - -// Runner runs a command in a directory and returns what it said. Injected so the tests do not -// need docker and git, and so the failure of either is reported rather than assumed. -type Runner func(ctx context.Context, dir string, name string, args ...string) (string, error) - -// Publisher puts an artifact somewhere a machine can fetch it, and says how to refer to it. -type Publisher interface { - // PublishImage pushes a locally built image and returns a reference pinned by digest. - PublishImage(ctx context.Context, localTag, repository string) (string, error) - // PublishArchive stores bytes and returns where to fetch them from. - PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error) -} - -// Result is everything one build produced. -type Result struct { - // Against is every pinned image this build was built on top of, read out of its own inputs. - // - // **Derived, not declared** (novox/hq ADR 0009): a declared list of dependencies drifts from - // what the code actually uses, and an artifact is out of date when anything it was built - // against moved. These are artifact references rather than module-versions, because that is - // what a build input names; resolving them to modules is the catalogue's work, since it is - // what knows which module-version published which artifact. - Against []string - - // Manifest is the module as the mesh should hold it: artifacts resolved to digests. - Manifest catalogue.Manifest - // Commit is what was built, so "is this current?" is answerable without building again. - Commit string - // Built is each artifact, for reporting. - Built []catalogue.Built - - // Read is every repository this build read source from besides the module's own — the second - // repository an artifact's recipe names (ArtifactContext). Reported because the manifest the - // mesh keeps carries no build section, so nothing else could say that a merge there is a - // change to this module (novox/hq 04-ISSUES/131). - Read []catalogue.ArtifactContext - - // Trunk is the repository's default branch as the forge holds it at the build, and OnTrunk whether - // the commit built is reachable from it (novox/hq ADR 0238): a commit off the trunk is checked, - // never published. Trunk empty is "could not be said". - Trunk string - OnTrunk bool - // Branches are every branch of the forge the commit is on: a module may follow a branch other than - // the default, and that branch is its trunk. - Branches []string - - // Mirrored is every base this build copied into the artifact store, as pinned (novox/hq ADR 0257): - // said whether the build worked or not, because the copy is in the store either way, and the - // records are what decide whether it stays. - Mirrored []string - - // Source is the build's source fingerprint (source.go): what it was made from — the module's tree, - // the contexts' trees, the bases and toolchains by digest — hashed. Empty where the source does not - // pin the build. Two builds with one fingerprint are one build, whatever digests they made - // (novox/hq issue 280). - Source string - - // SourceInputs are what Source was computed from (novox/hq ADR 0437 §5, issue 1215), set also where the source - // does not pin the build, so the reason is visible: with them delivery.SourceFingerprint gives Source again. - SourceInputs *delivery.SourceInputs - - // ReuseKey is the build's reuse key (novox/hq ADR 0350 §1): its source fingerprint's parts with the builder's - // version, and ReuseParts those parts; empty, with NoKey saying why, where the source does not pin the build. - ReuseKey string - ReuseParts map[string]string - NoKey string - // Reused is the check build a walk took instead of building (ADR 0350 §2), and Why, when it built, why it did - // not take one (§3). Both empty for a build not asked to reuse. - Reused *Reused - Why string - - // Sources are what this build was made from, as files (novox/hq ADR 0267 rule 1): per repository, the - // entries a changed file is tested against (SourceHolds). The module's own repository has an empty - // Repository. Said only for a build of a commit on the trunk, and only for a repository whose every - // artifact's build source is known — a Go program's import closure, an archive's directory, an image's - // recipe and the package it compiles; for any other, nothing is said and the whole of what the build - // sees stays its source, as before. - Sources []BuildSource - - // Predefined is the list of predefined keys at the root of the repository at the commit built (novox/hq ADR - // 0348 §7), which the manifest was judged against; nil for a repository that holds none. - Predefined *Predefined -} - -// Predefined is the list of predefined keys a build read: its text, canonical (catalogue.PredefinedList), and -// the generation of the commit built — how many commits it stands on, which orders two builds of one trunk. -type Predefined struct { - Keys []byte - Generation int -} - -// BuildSource is the build source a build read in one repository (novox/hq ADR 0267). -type BuildSource struct { - // Repository and Ref are a context's, as the manifest names it; empty for the module's own. - Repository string - Ref string - // Paths are the entries, relative to the repository's root (SourceHolds). - Paths []string -} - -// GitCredential is the forge credential a clone may present when the server asks for one. -// -// **Offered, never pushed.** It is written as a git credential-store file and named to git with -// `-c credential.helper=store`, so git itself decides when it applies: only on an authentication -// challenge, and only for the URL it was written for — scheme, host and port included. A public -// repository clones exactly as before, and a repository on any other host is never shown it. -type GitCredential struct { - // URL is the credential-store line — scheme://user:password@host[:port] — naming the one - // server this credential belongs to. Empty means the builder holds none and every clone is - // anonymous, as it always was. - URL string -} - -// Build clones a repository at a ref, reads its manifest, produces what it declares, publishes -// each, and returns the manifest the mesh should hold. -// -// **Nothing is published until everything is built.** A module whose image succeeded and whose -// archive failed would otherwise leave half of itself in the store under a digest the mesh never -// records — reachable, unreferenced, and indistinguishable from something in use. -func Build(ctx context.Context, run Runner, publish Publisher, - repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, - forge GitCredential, log Log, seats ...map[string]string) (Result, error) { - // **What was copied is said whether the build worked or not** (novox/hq ADR 0257): a base is in - // the store from the moment it is copied, and a build that failed after copying it is the - // one record that it is there. - var mirrored []string - result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log, - &mirrored, nil, seats...) - result.Mirrored = mirrored - return result, err -} - -// Reuse is what a walk's build may take instead of building (novox/hq ADR 0350 §2): the scratch namespace's check -// builds, and the pull requests the walk answers, whose checks name a differing key. -type Reuse struct { - Scratch Scratch - Pulls []PullRef - // Named are the builds the verdicts of the walk's own pull requests name (novox/hq ADR 0350 §2): a scratch build - // is taken only when one of them names its key and every digest. The record in the store alone is never enough. - Named []NamedBuild - // 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). -var errNoKey = errors.New("it has no reuse key") - -// BuildReusing is Build for the walk of a trunk commit (novox/hq ADR 0350 §2): before building, it takes the reuse -// key of the commit's tree, and where a check build holds the same key it copies that build into the module's -// namespace by digest and answers with it, building nothing. Otherwise it builds, and Result.Why says why (§3). -// Only a commit on its trunk takes a check build: anything else builds, as Build does. -func BuildReusing(ctx context.Context, run Runner, publish Publisher, reuse Reuse, - repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, - forge GitCredential, log Log, seats ...map[string]string) (Result, error) { - var mirrored []string - result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log, - &mirrored, &reuse, seats...) - result.Mirrored = mirrored - return result, err -} - -func build(ctx context.Context, run Runner, publish Publisher, - repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, - forge GitCredential, log Log, mirrored *[]string, reuse *Reuse, seats ...map[string]string) (Result, error) { - // The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers - // that hand none — tests of everything but contexts — read as they did. - var seatBases map[string]string - if len(seats) > 0 { - seatBases = seats[0] - } - - say := logging(log) - say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref)) - - // Made rather than required. A builder that fails because the directory it was told to work - // in does not exist is a builder that needs a setup step nobody documented. - if err := os.MkdirAll(workspace, 0o755); err != nil { - return Result{}, err - } - // **A node with no room takes no build** (novox/hq issue 1834). - if err := RoomToWork("/", workspace); err != nil { - say("clone", "refused: %v", err) - return Result{}, err - } - // The credential is a file git reads, never an argument: a URL carrying a password in argv - // would be readable by anything that can list processes for as long as a clone runs. - credentials, forget, err := storeCredential(workspace, forge) - if err != nil { - return Result{}, err - } - defer forget() - tree := filepath.Join(workspace, "source") - if err := os.RemoveAll(tree); err != nil { - return Result{}, err - } - // Removed when the build ends, whatever happens: the tree holds the .npmrc a build may be handed, in the - // workspace a later check's container mounts as its HOME (novox/hq issue 462). - defer os.RemoveAll(tree) - // A fresh clone every time rather than a fetch into a tree that is already there. A build - // that reuses a working tree can succeed because of something a previous build left behind, - // and that is a build nobody can reproduce. - if err := cloneFromForge(ctx, run, workspace, repository, tree, - cloneWith(credentials, "clone", "--quiet", repository, tree), say); err != nil { - say("clone", "FAILED: %v", err) - return Result{}, fmt.Errorf("cannot clone %s: %w", repository, err) - } - if err := recordedWithoutUserinfo(ctx, run, tree, repository); err != nil { - return Result{}, err - } - say("clone", "done") - if ref != "" { - if _, err := run(ctx, tree, "git", "checkout", "--quiet", ref); err != nil { - return Result{}, fmt.Errorf("%s has no %s: %w", repository, ref, err) - } - } - commit, err := run(ctx, tree, "git", "rev-parse", "HEAD") - if err != nil { - return Result{}, err - } - commit = strings.TrimSpace(commit) - say("commit", "%s", short(commit)) - // **Whether the commit is on the trunk** (novox/hq ADR 0238): only a commit on the repository's own - // default branch is published. Read from the clone just made — the forge's own word on which branch - // is its default and what it holds now — and said with the outcome, so the controller refuses to - // register a build of a commit off it. - trunk, onTrunk := trunkOf(ctx, run, tree, commit) - branches := branchesHolding(ctx, run, tree, commit) - if trunk != "" { - say("trunk", "%s is %son %s; on %s", short(commit), map[bool]string{true: "", false: "NOT "}[onTrunk], trunk, - orNoBranch(branches)) - } - - // A module is a repository and a path within it (novox/hq ADR 0069). The ordinary case is an - // empty path, meaning the repository's root; a repository holding several modules names each - // by its own directory, which is what the catalogue is and what the system this replaces has - // always done. - within, err := inside(tree, path) - if err != nil { - return Result{}, err - } - - raw, err := os.ReadFile(filepath.Join(within, ManifestName)) - if err != nil { - return Result{}, fmt.Errorf( - "%s has no %s at %s, so there is nothing saying what it is: %w", - repository, ManifestName, describe(path), err) - } - // **The list of predefined keys at the commit built** (novox/hq ADR 0348 §7): predefined.json at the - // repository's root, read strictly; a list that is wrong fails the build, and the manifest is judged against - // the list of its own tree, never against one the mesh published from another commit. - predefined, err := catalogue.ReadPredefined(tree) - if err != nil { - say("predefined", "INVALID: %v", err) - return Result{}, err - } - manifest, err := catalogue.ParseManifestWith(raw, predefined) - if err != nil { - say("manifest", "INVALID: %v", err) - return Result{}, err - } - var listed *Predefined - if predefined != nil { - text, err := predefined.Canonical() - if err != nil { - return Result{}, err - } - listed = &Predefined{Keys: text, Generation: generationOf(ctx, run, tree, commit)} - say("predefined", "%d key(s) at %s, generation %d", len(predefined), short(commit), listed.Generation) - } - // What is judged only where a definition is written (novox/hq ADR 0326 §3, issue 574): a build is - // where a changed definition is first read, so it is refused here before anything is made of it. - // Judged with each template's text in its file, as written in the tree (novox/hq ADR 0349 §2): a template the - // module's directory lacks is refused here, before anything is built. - judged, err := catalogue.WithTemplatesFrom(manifest, within) - if err != nil { - say("manifest", "INVALID: %v", err) - return Result{}, err - } - if problems := catalogue.WrittenSettingsProblems(judged); len(problems) > 0 { - err := fmt.Errorf("%s", strings.Join(problems, "; ")) - say("manifest", "INVALID: %v", err) - return Result{}, err - } - say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest)) - - // What it is made from, for its source fingerprint: the module's own tree first. - src := newSourceInputs(manifest.Module) - src.prefix = strings.Trim(filepath.ToSlash(filepath.Clean(path)), "/") - if src.prefix == "." { - src.prefix = "" - } - if src.tree, err = gitTree(ctx, run, tree, path); err != nil { - src.notPinned("its tree could not be named: " + err.Error()) - } - // A module falling back to a predefined key was judged against the list, so a change to the list is a change - // to it: the planner rebuilds it, and its build publishes the list (ADR 0348 §7). - if predefined != nil && manifest.NamesPredefined() { - src.rootHas(catalogue.PredefinedFile) - } - - // A build-time credential, written into the build context as .npmrc, but ONLY for a module that - // asks for it: a `package` artifact (which publishes), or an image whose Dockerfile COPYs .npmrc. - // Writing it into every context would put a per-run credential in `COPY . .` of modules that - // never resolve a mesh package — making their image non-deterministic (a needless rollout every - // build) and leaking the credential into a build stage. Absent entirely with no registry, which - // is the bootstrap case (novox/hq ADR 0076). - // **A build that needs the registry and has none is refused before anything is built or - // published** (novox/hq ADR 0336 §6) — before a base is copied into the store, and never built - // without the credential or against the public registry. - if !npmrc.Enabled() { - why, err := needsRegistry(manifest, within) - if err != nil { - return Result{}, err - } - if why != "" { - err := npmrc.unbound(manifest.Module, why) - say("packages", "REFUSED: %v", err) - return Result{}, err - } - } - var npmrcPath string - if npmrc.Enabled() && manifest.Build != nil && wantsPackages(manifest, within) { - content, err := npmrc.File() - if err != nil { - return Result{}, err - } - npmrcPath = filepath.Join(within, ".npmrc") - if err := os.WriteFile(npmrcPath, []byte(content), 0o600); err != nil { - return Result{}, fmt.Errorf("cannot write the package-registry credential for the build: %w", err) - } - // Only for as long as the build: a later check's container mounts this workspace (novox/hq issue 462). - defer os.Remove(npmrcPath) - say("packages", "resolving %s from the mesh's package registry, from %s", npmrc.Scope, fromOr(npmrc.From)) - src.notPinned("it resolves packages from the mesh's registry at build time") - } - - var built []catalogue.Built - // stoodOn is every base the build was handed, as resolved — the edges the catalogue derives. - var stoodOn []string - // reused is the check build a walk took, and builtWhy why it built instead (novox/hq ADR 0350). - var reused *Reused - builtWhy := "" - if manifest.Build != nil { - // What this module said it stands on, answered with what this mesh actually holds. Done - // before anything is built, so a missing base is refused in front of the person who can - // fix it rather than inside a build that stops on its own first line. - // An image published elsewhere that the build stands on is copied into the mesh's own - // registry first, like an upstream artifact (ADR 0096), and the recipe is handed the copy. - // Genesis has nowhere to copy to and pulls it into this machine's store instead. - mirror := func(ctx context.Context, from, repository, former string) (string, error) { - if m, can := publish.(BaseMirrorer); can { - say("bases", "copying %s into the mesh's registry as %s", from, repository) - reference, err := m.MirrorBase(ctx, from, repository, former) - if err == nil { - *mirrored = append(*mirrored, reference) - } - return reference, err - } - if m, can := publish.(Mirrorer); can { - say("bases", "copying %s into the mesh's registry as %s", from, repository) - reference, err := m.MirrorImage(ctx, from, repository) - if err == nil { - *mirrored = append(*mirrored, reference) - } - return reference, err - } - if _, err := run(ctx, tree, "docker", "pull", from); err != nil { - return "", fmt.Errorf("cannot fetch %s: %w", from, err) - } - return from, nil - } - args, bases, err := standingOn(ctx, manifest, held, mirror) - if err != nil { - say("bases", "UNMET: %v", err) - return Result{}, err - } - stoodOn = bases - src.bases = append(src.bases, bases...) - if len(args) > 0 { - say("bases", "%d resolved from what the mesh holds", len(args)/2) - } - artifacts := append([]catalogue.Artifact{}, manifest.Build.Artifacts...) - // Ordered, so two builds of one commit do the same work in the same sequence and their - // logs can be compared. - sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) - // **A walk takes the check's build of the same key** (novox/hq ADR 0350 §2): only a commit on its trunk, and - // 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, - held, npmrcPath, npmrc, seatBases, src, say) - if err == nil && key.Key == "" { - err = fmt.Errorf("%w: %s", errNoKey, key.Why) - } - if err != nil { - 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) - if taken != nil { - built = taken.Made - src = keyed - reused = taken - } else { - builtWhy = why - say("reuse", "built: %s", why) - } - } - for _, a := range artifacts { - if reused != nil { - break - } - say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a)) - made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, src, say) - if err != nil { - say("artifact", "%s FAILED: %v", a.Name, err) - return Result{}, err - } - say("artifact", "%s done — %s", a.Name, describeMade(made)) - built = append(built, made) - } - } - - // Each template's text, read back from the archive the build made or took (novox/hq ADR 0349 §2). - if built, err = withTemplateTexts(manifest, within, built); err != nil { - say("templates", "FAILED: %v", err) - return Result{}, err - } - resolved, err := manifest.Resolve(built) - if err != nil { - say("resolve", "FAILED: %v", err) - return Result{}, err - } - say("done", "%s at %s — %d artifact(s) pinned", manifest.Module, short(commit), len(built)) - fingerprint := src.fingerprint() - if fingerprint == "" { - say("source", "no source fingerprint: %s", orNoTree(src.unpinned)) - } - // **Only a build of the trunk's head says its build source** (novox/hq ADR 0267): the planner maps the - // next merge onto the build source of the newest build, and a branch's closure — or an older trunk - // commit's, built by hand — is not the trunk's. Nor does a build whose context was not its trunk's head. - var sources []BuildSource - if trunk != "" && onTrunk && src.contextsAtHead && atTrunkHead(ctx, run, tree, commit, trunk) { - sources = src.buildSources() - for _, s := range sources { - where := "its own repository" - if s.Repository != "" { - where = s.Repository - } - say("source", "%d path(s) of %s", len(s.Paths), where) - } - } - // The reuse key (novox/hq ADR 0350 §1): what a check keeps its build under, and what a walk takes one by. - key := keyOf(src, manifest, Version) - return Result{Manifest: resolved, Commit: commit, Built: built, - Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint, SourceInputs: resultInputs(src), - Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources, - ReuseKey: key.Key, ReuseParts: key.Parts, NoKey: key.Why, Reused: reused, Why: builtWhy, - Predefined: listed}, nil -} - -// generationOf is how many commits the commit built stands on, itself included: of two commits on one trunk the -// later has more, so the controller keeps the list of the newer one whatever order it hears them in (ADR 0348 -// §7). 0 when git could not say. -func generationOf(ctx context.Context, run Runner, tree, commit string) int { - out, err := run(ctx, tree, "git", "rev-list", "--count", commit) - if err != nil { - return 0 - } - n, err := strconv.Atoi(strings.TrimSpace(out)) - if err != nil || n < 0 { - return 0 - } - return n -} - -// takeACheckBuild is the walk's look for a check build of its key (novox/hq ADR 0350 §2, §3): the key taken by -// the steps a build takes, each artifact stopped before it is built; the build kept under it copied by digest into -// the module's namespace. Answers what it took and the source inputs its key was taken from, or why it builds. A -// copy that fails is said, and the walk builds (§3): it never fails the walk on its own. -func takeACheckBuild(ctx context.Context, run Runner, publish Publisher, reuse Reuse, manifest catalogue.Manifest, - within, workspace, commit, credentials string, artifacts []catalogue.Artifact, args []string, - held map[string]string, npmrcPath string, npmrc Npmrc, seats map[string]string, src *sourceInputs, onTrunk bool, - say func(step, format string, args ...any)) (*Reused, *sourceInputs, string) { - if !onTrunk { - return nil, nil, "the commit is not on its trunk, and only a trunk commit's walk takes a check's build" - } - keyed, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args, - held, npmrcPath, npmrc, seats, src, say) - if err != nil { - return nil, nil, err.Error() - } - if key.Key == "" { - return nil, nil, "it has no reuse key: " + key.Why - } - found, ok, err := reuse.Scratch.Find(ctx, manifest.Module, key.Key) - if err != nil { - return nil, nil, "the scratch namespace could not be read: " + err.Error() - } - if !ok { - return nil, nil, notKeptWhy(ctx, reuse, manifest.Module, key) - } - // **Only a build a verdict names** (ADR 0350 §2): the store takes writes from anything on the network, so a - // record is believed only when a check verdict of the walk's own pull requests names its key and its digests. - if why := unnamed(reuse.Named, manifest.Module, key.Key, found); why != "" { - return nil, nil, why - } - taken := &Reused{From: found} - for _, made := range found.Made { - copied, err := reuse.Scratch.Take(ctx, manifest.Module, made) - if err != nil { - why := fmt.Sprintf("copying %s's %s from %s failed (%v)", manifest.Module, made.Name, found.Said(), err) - say("reuse", "%s; built instead", why) - return nil, nil, why - } - taken.Made = append(taken.Made, copied) - } - // Every artifact the manifest declares, or none: a check build missing one is not this build. - if len(taken.Made) != len(artifacts) { - return nil, nil, fmt.Sprintf("%s holds %d artifact(s) of %s, which declares %d", found.Said(), len(taken.Made), - manifest.Module, len(artifacts)) - } - say("reuse", "%s", taken.Words()) - 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, - within, workspace, commit, credentials string, artifacts []catalogue.Artifact, args []string, - held map[string]string, npmrcPath string, npmrc Npmrc, seats map[string]string, src *sourceInputs, - say func(step, format string, args ...any)) (*sourceInputs, reuseKey, error) { - keyed := src.copy() - keyed.onlyKey = true - for _, a := range artifacts { - if _, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, - npmrcPath, npmrc, seats, keyed, say); err != nil { - return nil, reuseKey{}, fmt.Errorf("its reuse key could not be taken: %w", err) - } - } - keyed.onlyKey = false - return keyed, keyOf(keyed, manifest, Version), nil -} - -// notKeptWhy is why no check build has the key (§3): no check of the walk's pull requests built the module, the -// key differs — naming the part — or the build it made is gone. -func notKeptWhy(ctx context.Context, reuse Reuse, module string, key reuseKey) string { - if len(reuse.Pulls) == 0 { - return "no check built it with this key" - } - builds, err := reuse.Scratch.OfPulls(ctx, reuse.Pulls) - if err != nil { - return "no check build has this key, and which checks built it could not be read: " + err.Error() - } - for _, b := range builds { - if b.Module != module { - continue - } - if b.Key == key.Key { - return b.Said() + " built it with this key, and it was removed" - } - return "the key differs from " + b.Said() + "'s in " + strings.Join(differing(b.Parts, key.Parts), ", ") - } - var pulls []string - for _, p := range reuse.Pulls { - pulls = append(pulls, p.String()) - } - return "no check of " + strings.Join(pulls, ", ") + " built it (a module its check did not move, or a check " + - "that did not finish)" -} - -// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`. -func branchesHolding(ctx context.Context, run Runner, clone, commit string) []string { - out, err := run(ctx, clone, "git", "branch", "--remotes", "--format=%(refname:short)", "--contains", commit) - if err != nil { - // empty-on-error: no branch said is the commit on none, which refuses its registration — never a pass - return nil - } - var branches []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if b, ok := strings.CutPrefix(line, "origin/"); ok && b != "HEAD" && b != "" { - branches = append(branches, b) - } - } - sort.Strings(branches) - return branches -} - -func orNoBranch(branches []string) string { - if len(branches) == 0 { - return "no branch" - } - return strings.Join(branches, ", ") -} - -// trunkOf is a fresh clone's trunk — the branch the forge names its default, as `origin/HEAD` says — and -// whether a commit is reachable from it. Empty when the clone does not say, which is "not known", never -// "on it". -func trunkOf(ctx context.Context, run Runner, clone, commit string) (string, bool) { - head, err := run(ctx, clone, "git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") - if err != nil { - return "", false - } - remote := strings.TrimSpace(head) - trunk := strings.TrimPrefix(remote, "origin/") - if trunk == "" || trunk == remote { - return "", false - } - _, err = run(ctx, clone, "git", "merge-base", "--is-ancestor", commit, remote) - return trunk, err == nil -} - -// atTrunkHead is whether a clone's commit is its trunk's head as the clone holds it. -func atTrunkHead(ctx context.Context, run Runner, clone, commit, trunk string) bool { - head, err := run(ctx, clone, "git", "rev-parse", "origin/"+trunk) - if err != nil { - return false - } - at, err := run(ctx, clone, "git", "rev-parse", commit) - if err != nil { - return false - } - return strings.TrimSpace(head) != "" && strings.TrimSpace(head) == strings.TrimSpace(at) -} - -// cleanEntry is a path of the module's directory as an entry: cleaned, relative, `.` for the directory. -func cleanEntry(p string) string { - c := strings.Trim(path.Clean("/"+filepath.ToSlash(p)), "/") - if c == "" { - return "." - } - return c -} - -// orNoTree is why a build has no source fingerprint, for its log. -func orNoTree(why string) string { - if why == "" { - return "its tree was not named" - } - return why -} - -// Log is where a build says what it is doing, step by step. Nil is silent — the tests pass none, -// and a build with nowhere to speak must still build. -type Log func(step, message string) - -func logging(log Log) func(step, format string, args ...any) { - if log == nil { - return func(string, string, ...any) {} - } - return func(step, format string, args ...any) { - log(step, fmt.Sprintf(format, args...)) - } -} - -// keepsImageLocally says the publisher leaves the image in the machine's runtime as the artifact itself. -func keepsImageLocally(publish Publisher) bool { - switch publish.(type) { - case Local, *Local: - return true - } - return false -} - -// PushedNames is implemented by a publisher that tags the image under other names to push it: the names it leaves in the -// machine's runtime for a repository, which go with the build's own tag (novox/hq issue 1834). -type PushedNames interface { - PushedNames(repository string) []string -} - -// releaseImage removes the names an image was given on the node, in one command: its layers go only with the last. It runs -// with a context of its own, the build's may be cancelled. A name never made (the build failed before it) is no fault; -// one that cannot be removed is said, and removal is said only when the image is gone. -func releaseImage(ctx context.Context, run Runner, names []string, say func(step, format string, args ...any)) { - removing, done := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) - defer done() - if _, err := run(removing, "", "docker", append([]string{"rmi"}, names...)...); err != nil { - if !strings.Contains(err.Error(), "No such image") { - say("image", "the local image %s could not be removed, and goes with the week's sweep: %v", strings.Join(names, ", "), err) - } - return - } - say("image", "the local image %s is removed: the registry holds it", strings.Join(names, ", ")) -} - -// contextFrom clones an image artifact's own build context, when it names one apart from this -// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact -// name so two artifacts of one module naming different contexts do not collide. -func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string, - from catalogue.ArtifactContext, seats map[string]string, say func(step, format string, args ...any)) (_ string, err error) { - url, err := contextURL(from, seats) - if err != nil { - return "", err - } - say("context", "cloning %s at %s for %s", url, refOrHead(from.Ref), artifact) - dir := filepath.Join(workspace, "context-"+artifact) - if err := os.RemoveAll(dir); err != nil { - return "", err - } - // A clone that failed half-way is no context to leave behind (novox/hq issue 1834). - defer func() { - if err != nil { - os.RemoveAll(dir) - } - }() - if err := cloneFromForge(ctx, run, workspace, url, dir, cloneWith(credentials, "clone", "--quiet", url, dir), say); err != nil { - return "", fmt.Errorf("cannot clone %s: %w", url, err) - } - if err := recordedWithoutUserinfo(ctx, run, dir, url); err != nil { - return "", err - } - if from.Ref != "" { - if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil { - return "", fmt.Errorf("%s has no %s: %w", from.Repository, from.Ref, err) - } - } - say("context", "done") - return dir, nil -} - -// contextURL is what a context is cloned from: its URL, or — for a context on a seat — the seat's -// clone base the mesh sent with the request joined to the repository's path (novox/hq ADR 0155). -// Refused, never guessed, when the mesh sent no base for that seat: a builder that guessed a forge -// would be the literal this removes, one layer down. -func contextURL(from catalogue.ArtifactContext, seats map[string]string) (string, error) { - if from.Seat == "" { - return from.Repository, nil - } - base, told := seats[from.Seat] - if !told || base == "" { - return "", fmt.Errorf("the context is %s on the %s seat, and this build was told no clone "+ - "base for that seat — nothing holds it in this mesh, or the control plane predates the word", - from.Repository, from.Seat) - } - return strings.TrimRight(base, "/") + "/" + strings.TrimSuffix(strings.Trim(from.Repository, "/"), ".git") + ".git", nil -} - -// storeCredential writes the forge credential where git's credential store reads it, and the function that -// removes it again; "" and nothing to remove when the builder holds none. -// -// **Never inside the workspace** (novox/hq issue 462): a merge check's toolchain container mounts the -// workspace as its HOME, so a credential kept there — even one a build left behind — is readable by any pull -// request's merge-check.sh, and what it prints is kept on the bus. So it lives in a directory of its own -// outside the workspace, made private, and a credential an older builder left in the workspace is removed. -func storeCredential(workspace string, forge GitCredential) (string, func(), error) { - if err := os.Remove(filepath.Join(workspace, "git-credentials")); err != nil && !errors.Is(err, os.ErrNotExist) { - return "", nil, fmt.Errorf("a credential left in the workspace cannot be removed: %w", err) - } - if forge.URL == "" { - return "", func() {}, nil - } - dir, err := os.MkdirTemp("", "mesh-forge-credential-") - if err != nil { - return "", nil, err - } - forget := func() { os.RemoveAll(dir) } - if withinDir(workspace, dir) { - forget() - return "", nil, fmt.Errorf("the temporary directory %s is inside the workspace %s, which a check's "+ - "container mounts: the forge credential is not written where it could read it", dir, workspace) - } - path := filepath.Join(dir, "git-credentials") - if err := os.WriteFile(path, []byte(forge.URL+"\n"), 0o600); err != nil { - forget() - return "", nil, err - } - return path, forget, nil -} - -// recordedWithoutUserinfo makes a clone record the URL it came from without userinfo: git keeps it as given in -// the clone's .git/config, inside the workspace a check's container mounts (novox/hq issue 462). -func recordedWithoutUserinfo(ctx context.Context, run Runner, clone, repository string) error { - bare, carried := withoutUserinfo(repository) - if !carried { - return nil - } - if _, err := run(ctx, clone, "git", "remote", "set-url", "origin", bare); err != nil { - return fmt.Errorf("the clone of %s keeps its credential: %w", bare, err) - } - return nil -} - -// forgetLeftCredentials removes what an earlier build or an older builder left in the workspace that holds -// a credential: the forge's git-credentials, and every .npmrc a build wrote into a tree it cloned. Run -// before a check, whose container mounts the workspace as its HOME (novox/hq issue 462). The Go caches are -// not walked: no build writes a credential there, and they hold more files than everything else. -func forgetLeftCredentials(workspace string) error { - if err := os.Remove(filepath.Join(workspace, "git-credentials")); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return filepath.WalkDir(workspace, func(p string, d fs.DirEntry, err error) error { - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - if d.IsDir() && p != workspace && (d.Name() == "go-cache" || d.Name() == "go-modules") && - filepath.Dir(p) == workspace { - return filepath.SkipDir - } - if !d.IsDir() && d.Name() == ".npmrc" { - if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("a package-registry credential left at %s cannot be removed: %w", p, err) - } - } - return nil - }) -} - -// withinDir is whether path is dir or under it, both made absolute and resolved. -func withinDir(dir, path string) bool { - d, err1 := filepath.Abs(dir) - p, err2 := filepath.Abs(path) - if err1 != nil || err2 != nil { - return true - } - if r, err := filepath.EvalSymlinks(d); err == nil { - d = r - } - if r, err := filepath.EvalSymlinks(p); err == nil { - p = r - } - rel, err := filepath.Rel(d, p) - return err != nil || rel == "." || filepath.IsLocal(rel) -} - -// cloneWith is a git invocation that may offer a stored credential. -// -// The first `-c credential.helper=` clears every helper the environment might carry, so exactly -// one place answers an authentication challenge: the file the builder wrote. Without a file, the -// invocation is exactly what it always was. -func cloneWith(credentials string, rest ...string) []string { - if credentials == "" { - return rest - } - return append([]string{ - "-c", "credential.helper=", - "-c", "credential.helper=store --file=" + credentials, - }, rest...) -} - -func describePath(path string) string { - if path == "" { - return "" - } - return " at " + path -} - -func refOrHead(ref string) string { - if ref == "" { - return "HEAD" - } - return ref -} - -func artifactCount(m catalogue.Manifest) int { - if m.Build == nil { - return 0 - } - return len(m.Build.Artifacts) -} - -func langSuffix(a catalogue.Artifact) string { - if a.Language != "" { - return ", " + a.Language - } - return "" -} - -func describeMade(made catalogue.Built) string { - if made.Digest != "" { - return made.Kind + " " + short(strings.TrimPrefix(made.Digest, "sha256:")) - } - return made.Kind + " " + made.Reference -} - -// inside resolves a module's path within a clone, and refuses one that leaves it. -// -// **A build reads only its own tree.** A path of `../../etc` would otherwise make a build read — -// and an archive artifact publish — whatever the build machine happens to hold, which is the one -// thing a machine that builds other people's repositories must not do. -func inside(tree, path string) (string, error) { - if path == "" { - return tree, nil - } - if filepath.IsAbs(path) { - return "", fmt.Errorf( - "a module's path is inside its repository, and %q is an absolute path", path) - } - within := filepath.Join(tree, path) - rel, err := filepath.Rel(tree, within) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf( - "%q leaves the repository, and a build reads only its own tree", path) - } - // A link in the fetched tree must not lead the module out of it either (issue 1609): compare where - // both really are. A module directory that does not exist is left to the read of its manifest. - realTree, err := filepath.EvalSymlinks(tree) - if err != nil { - return "", err - } - realWithin, err := filepath.EvalSymlinks(within) - if errors.Is(err, os.ErrNotExist) { - return within, nil - } - if err != nil { - return "", err - } - if rel, err := filepath.Rel(realTree, realWithin); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("%q leads out of the repository through a link: it resolves to %s, and the repository is %s", - path, realWithin, realTree) - } - return within, nil -} - -// describe says where a manifest was looked for, in words a person can act on. -func describe(path string) string { - if path == "" { - return "its root" - } - return path -} - -// pinnedImage matches an image reference pinned by digest, which is the only kind a build input is -// allowed to name — a tag is something somebody else can move under you. -var pinnedImage = regexp.MustCompile(`[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[0-9a-f]{64}`) - -// against is what this module's image artifacts are built on top of: every base the mesh resolved -// and handed the recipe as a build argument (`build.on`), and any image a recipe pins by digest -// itself. Nothing is guessed: a reference that was neither resolved nor written down is not -// reported. -// -// **The resolved bases are the edges.** A recipe reads its base from an argument (`FROM -// ${RUNTIME_BASE}`), so the digest is never in the file, and a derivation that read files alone -// recorded no edge for any module on the mesh — which is why nothing knew what a changed base -// meant to rebuild (novox/hq 04-ISSUES/131). -func against(within string, manifest catalogue.Manifest, resolved []string) []string { - if manifest.Build == nil { - return nil - } - seen := map[string]bool{} - var out []string - for _, r := range resolved { - if r != "" && !seen[r] { - seen[r] = true - out = append(out, r) - } - } - for _, a := range manifest.Build.Artifacts { - if a.Kind != catalogue.ArtifactImage || a.From == "" { - continue - } - body, err := os.ReadFile(filepath.Join(within, a.From)) - if err != nil { - // Not fatal: the build itself already failed if this file was needed and missing, and - // reporting no edges is honest where inventing them would not be. - continue - } - for _, found := range pinnedImage.FindAllString(string(body), -1) { - if !seen[found] { - seen[found] = true - out = append(out, found) - } - } - } - sort.Strings(out) - return out -} - -// fromOr is where a registry was resolved from, as a build's log says it. -func fromOr(from string) string { - if from == "" { - return "what this build was handed" - } - return from -} - -// ManifestName is the one file a module repository must have. -// -// At the root, and named the same in every repository. A convention somebody can look for beats a -// setting somebody has to find. -const ManifestName = "module.json" - -// wantsPackages reports whether this module's build resolves anything from the mesh's package -// registry, so the credential is written into its context only then. A package artifact always -// does; an image does when its Dockerfile names .npmrc — the file it would COPY to authenticate. -func wantsPackages(manifest catalogue.Manifest, within string) bool { - for _, a := range manifest.Build.Artifacts { - switch a.Kind { - case catalogue.ArtifactPackage: - return true - case catalogue.ArtifactImage: - raw, err := os.ReadFile(filepath.Join(within, a.From)) - if err == nil && strings.Contains(string(raw), ".npmrc") { - return true - } - } - } - return false -} - -func one(ctx context.Context, run Runner, publish Publisher, - module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string, - held map[string]string, npmrc string, registry Npmrc, seats map[string]string, src *sourceInputs, - say func(step, format string, args ...any)) (catalogue.Built, error) { - - switch a.Kind { - case catalogue.ArtifactUpstream: - // Its key is the image it copies, read from the manifest (keyOf): nothing to take here. - if src.keyOnly() { - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - // Mirrored, not built: copied under a name of the mesh's own, so what a machine fetches is - // pinned by a digest this registry assigned rather than by a tag somebody else can move. - // - // **Between registries, never through this machine's image store** (novox/hq - // 04-ISSUES/046, ADR 0096). A published image is an index over several architectures; - // pulled, the store keeps the index and refuses to push one platform out of it, and - // every variant of pull-then-push failed the same way. A copy moves what is there. - if mirror, can := publish.(Mirrorer); can { - say("mirror", "copying %s into the mesh's registry", a.From) - reference, err := mirror.MirrorImage(ctx, a.From, module+"/"+a.Name) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %w", module, err) - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil - } - // Genesis has no registry to copy into: the image stays in this machine's store, named by - // its own id, as every artifact does before there is anywhere to publish. - say("mirror", "pulling %s", a.From) - if _, err := run(ctx, tree, "docker", "pull", a.From); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: cannot fetch %s: %w", module, a.From, err) - } - reference, err := publish.PublishImage(ctx, a.From, module+"/"+a.Name) - if err != nil { - return catalogue.Built{}, err - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil - - case catalogue.ArtifactImage: - // Tagged by commit rather than by version, because a version is what a person calls a - // release and a commit is what was actually built. The mesh pins the digest anyway; this - // is only so a person looking at the build node can tell what is there. - local := fmt.Sprintf("%s-%s:%s", module, a.Name, short(commit)) - // The bases this module named, resolved to what this mesh holds. A recipe reads them as - // build arguments, so a module says which module it stands on and never which copy. - // **A recipe fetches nothing the manifest did not declare** (novox/hq 04-ISSUES/064). A FROM - // or a COPY --from naming a registry image that is not a declared base is a build that - // reaches a public registry on its own — and works when that registry answers, which is - // sometimes. Refused here, in front of the person who can declare it, not inside a build - // that fails with "pull access denied" for a reason that is not the mesh's. - recipe, err := os.ReadFile(filepath.Join(tree, a.From)) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: cannot read the recipe %s: %w", module, a.From, err) - } - declared := map[string]bool{} - for i := 0; i+1 < len(args); i += 2 { - if args[i] == "--build-arg" { - declared[strings.SplitN(args[i+1], "=", 2)[0]] = true - } - } - bases, copies := undeclaredFetches(string(recipe), declared) - if len(copies) > 0 { - return catalogue.Built{}, fmt.Errorf( - "%s: the recipe %s copies out of %s, which the manifest does not declare. A build "+ - "reaching a public registry on its own works only when that registry answers; "+ - "declare it under build.on as {\"arg\": \"\", \"image\": \"@sha256:…\"} "+ - "and read it from that argument (novox/hq ADR 0097)", - module, a.From, strings.Join(copies, ", ")) - } - if len(bases) > 0 { - // Refused, since the mesh's own images declare theirs (ADR 0097): a base fetched on - // its own is a build that works when a public registry answers, which is sometimes. - return catalogue.Built{}, fmt.Errorf( - "%s: the recipe %s starts FROM %s, which the manifest does not declare. Declare "+ - "each under build.on as {\"arg\": \"\", \"image\": \"@sha256:…\"} "+ - "and start FROM ${} (novox/hq ADR 0097)", - module, a.From, strings.Join(bases, ", ")) - } - // The recipe is always read from this module's own tree, at this module's own commit — only - // the context docker build's final argument names can come from somewhere else, when the - // artifact says so. - recipePath := a.From - buildDir := tree - if a.Context != nil { - cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, seats, say) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err) - } - if t, err := gitTree(ctx, run, cloned, ""); err != nil { - src.notPinned(a.Name + "'s context could not be named: " + err.Error()) - } else if src != nil { - src.contexts[a.Name] = t - } - buildDir = cloned - // Let go of when the image is built, whatever became of it (novox/hq issue 1834). - defer os.RemoveAll(cloned) - if t, _ := trunkOf(ctx, run, cloned, "HEAD"); t == "" || !atTrunkHead(ctx, run, cloned, "HEAD", t) { - src.contextOffHead() - } - } - // docker build accepts -f outside the context it is given; the recipe stays exactly where it was - // read from and validated against, absolute so a context elsewhere does not change which file - // that is. - if buildDir != tree || a.Compiles != "" { - absRecipe, err := filepath.Abs(filepath.Join(tree, a.From)) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err) - } - recipePath = absRecipe - } - // **An image that compiles a Go program is handed its build source and nothing else** (novox/hq - // ADR 0267 rules 1 and 3): the program's import closure, read from the context it is built in, so a - // merge elsewhere in that repository is no change to it — and a recipe that copies a file outside - // it fails here, naming the file, rather than building from something no merge is mapped onto. - if a.Compiles != "" { - paths, err := GoBuildSource(buildDir, a.Compiles) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s compiles %s, whose build source cannot be read: %w", - module, a.Name, a.Compiles, err) - } - narrowed := filepath.Join(workspace, "narrow-"+a.Name) - sum, err := narrowTree(buildDir, narrowed, paths) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: handing %s its build source: %w", module, a.Name, err) - } - say("image", "%s is handed its build source: %d path(s) of %s", a.Name, len(paths), a.Compiles) - if a.Context != nil { - src.contexts[a.Name] = sum - src.readIn(*a.Context, paths) - } else { - src.ownHas(paths...) - } - buildDir = narrowed - defer os.RemoveAll(narrowed) - } else if a.Context != nil { - src.readWhole(*a.Context) - } - if a.Compiles != "" || a.Context != nil { - src.ownHas(cleanEntry(a.From)) - } else { - src.ownWhole() - } - // Taken for a reuse key (novox/hq ADR 0350 §2): everything its fingerprint reads is read; nothing is built. - if src.keyOnly() { - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...) - if a.Target != "" { - invocation = append(invocation, "--target", a.Target) - } - if npmrc != "" { - // Host network for the build, so a RUN reaching the package registry finds it where the - // binding says it is — the machine's own loopback, where the registry answers. The - // credential itself is in the context as .npmrc, COPY'd by a stage that is not published; - // buildkit is not required, because this machine's docker may not carry buildx. - invocation = append(invocation, "--network", "host") - } - invocation = append(invocation, ".") - say("image", "docker build -f %s", recipePath) - // **The local tag goes with the build** (novox/hq issue 1834): an image published to a registry is held there, and - // a tag per module and commit left on the node was the 96 GB of a full disk. Only a node publishing nowhere (a - // mesh being raised) keeps it, since the local image is then the artifact. - if !keepsImageLocally(publish) { - names := []string{local} - if p, ok := publish.(PushedNames); ok { - names = append(names, p.PushedNames(module+"/"+a.Name)...) - } - defer releaseImage(ctx, run, names, say) - } - if _, err := run(ctx, buildDir, "docker", invocation...); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err) - } - say("image", "built, publishing") - // Its bases linked into its repository first, so the push needs none of their blobs from this - // machine's store, which may not hold them (novox/hq issue 694). Said and not fatal: a push - // from a store that holds them works without, and one that fails says which blob it lacked. - if lender, can := publish.(BaseLender); can { - if n, err := lender.LendBases(ctx, module+"/"+a.Name, buildArgValues(args)); err != nil { - say("image", "its bases could not all be linked into %s/%s before the push: %v", module, a.Name, err) - } else if n > 0 { - say("image", "%d blob(s) of its bases linked into %s/%s before the push", n, module, a.Name) - } - } - reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name) - if err != nil { - return catalogue.Built{}, err - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil - - case catalogue.ArtifactBundle: - // **The one recipe that both builds and packs.** Everything else either produces an image - // or packs what is already there; this compiles the module's own code first, in a - // toolchain the mesh chose from what the module said it was written in, and packs the - // result. - // - // The compiler runs in a container rather than on the build machine, for the reason every - // other build does: what a build needs installed is the toolchain's business, and a build - // machine that accumulated one toolchain per language would be a machine nobody could - // reproduce. - chain, err := ToolchainFor(a.Language) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err) - } - base, ok := held[chain.Base+"/"+chain.Artifact] - if !ok { - // Named, not pinned: the mesh answers with the copy it holds. Refused before anything - // is built, saying which module has to exist first, rather than failing inside a - // compile with a message about an image (novox/hq 04-ISSUES/044). - return catalogue.Built{}, fmt.Errorf( - "%s: %s is written in %s, which is compiled by %s's %q artifact, and this mesh "+ - "holds no copy of it. Build %s first", - module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base) - } - if src != nil { - src.toolchains[a.Name] = toolchainOf(chain, base) - } - // A Go program's build source is its import closure (novox/hq ADR 0267 rule 1). Not read, it is the - // module's whole directory, as before — said, so the wider plan has a reason a person can find. - if chain.Language == "go" { - if paths, err := GoBuildSource(tree, a.From); err != nil { - say("bundle", "%s's build source is its whole directory: %v", a.Name, err) - src.ownWhole() - } else { - src.ownHas(paths...) - } - } else { - src.ownWhole() - } - if chain.Language == "typescript" { - if own, _ := ownDependencies(tree); len(own) > 0 { - src.notPinned(a.Name + " resolves packages of its own at build time") - } - } - if src.keyOnly() { - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - // The module's own packages first, where the compiler and the bundler resolve them from - // (dependencies.go); nothing at all for a module whose package.json names only the SDK. - if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err) - } - say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base) - compiled, err := compile(ctx, run, tree, chain, base, a) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err) - } - // **Every entrypoint the runtime may serve is executable** (novox/hq ADR 0193). The runtime - // knows no language; for one that runs through an interpreter the build writes the launcher. - launchers, err := writeLaunchers(compiled, chain, a) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err) - } - if chain.Bundler != "" { - say("bundle", "bundling each entrypoint into one file") - if compiled, err = bundled(ctx, run, tree, chain, base, a, launchers); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: bundling %s failed: %w", module, a.Name, err) - } - } - say("bundle", "compiled, packing") - if err := outsideTheModule(tree, compiled); err != nil { - return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) - } - body, err := pack(compiled) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) - } - sum := sha256.Sum256(body) - digest := "sha256:" + hex.EncodeToString(sum[:]) - where, err := publish.PublishArchive(ctx, module+"/"+a.Name, body, digest) - if err != nil { - return catalogue.Built{}, err - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil - - case catalogue.ArtifactPackage: - // Built and published on a public base, to the mesh's package registry, by version - // (novox/hq ADR 0076). Not an image, not an archive: nothing on a machine references it, so - // there is no Publisher call — the container itself publishes, with the credential the - // build was handed. - say("package", "building and publishing %s (%s)", a.Name, a.Language) - src.ownWhole() - src.notPinned(a.Name + " is a package, built from what the registry holds when it is built") - if src.keyOnly() { - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: publishing %s failed: %w", module, a.Name, err) - } - say("package", "published %s", reference) - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil - - case catalogue.ArtifactArchive: - if src.keyOnly() { - if a.URL == "" { - src.ownHas(cleanEntry(a.From) + "/**") - } - return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil - } - var body []byte - var err error - if a.URL != "" { - // An upstream file pinned by URL and sha256 (novox/hq ADR 0316): its build source is the pin, which - // is in module.json, always part of it. - body, err = upstreamArchive(ctx, workspace, a, say) - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err) - } - } else { - src.ownHas(cleanEntry(a.From) + "/**") - var root string - if root, err = archiveRoot(tree, a); err == nil { - body, err = pack(root) - } - } - if err != nil { - return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) - } - sum := sha256.Sum256(body) - digest := "sha256:" + hex.EncodeToString(sum[:]) - where, err := publish.PublishArchive(ctx, module+"/"+a.Name, body, digest) - if err != nil { - return catalogue.Built{}, err - } - return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil - } - return catalogue.Built{}, fmt.Errorf("%s: %q is a %q, which is not something this builds", - module, a.Name, a.Kind) -} - -// archiveRoot is the directory an archive artifact is packed from, refused when a link in the fetched -// tree leads out of the module. The repository is somebody else's: pack follows a link in a parent -// component of `from`, so without this a committed link makes the build pack, register and serve any -// directory the build account can read (issue 1609). Both are compared where they really are. -func archiveRoot(tree string, a catalogue.Artifact) (string, error) { - root := filepath.Join(tree, a.From) - realTree, err := filepath.EvalSymlinks(tree) - if err != nil { - return "", err - } - realRoot, err := filepath.EvalSymlinks(root) - if err != nil { - return "", err - } - if rel, err := filepath.Rel(realTree, realRoot); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("the archive %s is taken from %q, which lies outside the module: it resolves to %s, and the module is %s", - a.Name, a.From, realRoot, realTree) - } - return root, nil -} - -// outsideTheModule refuses a path that, once every link in it is followed, is not inside the module's -// directory, naming where it really is and where the module is. For a path not there yet (an output -// directory about to be made) the deepest part that exists is the one followed. A repository commits -// its own links, so `.mesh-build` as a link would otherwise have a bundle's build write into, and pack, -// any directory the build account can read (issue 1609). -func outsideTheModule(tree, path string) error { - realTree, err := filepath.EvalSymlinks(tree) - if err != nil { - return err - } - real, rest := path, "" - for { - resolved, err := filepath.EvalSymlinks(real) - if err == nil { - real = filepath.Join(resolved, rest) - break - } - if !errors.Is(err, os.ErrNotExist) || filepath.Dir(real) == real { - return err - } - rest = filepath.Join(filepath.Base(real), rest) - real = filepath.Dir(real) - } - if rel, err := filepath.Rel(realTree, real); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return fmt.Errorf("%s leads out of the module through a link: it resolves to %s, and the module is %s", - path, real, realTree) - } - return nil -} - -// pack tars and gzips a directory. -// -// **Deterministically**: entries sorted, and no timestamps, uid, gid or original names carried -// through. Two builds of one commit must produce one digest, or nothing downstream can tell "this -// changed" from "this was built again" — and every rebuild would look like a change to every -// machine holding it. -func pack(root string) ([]byte, error) { - info, err := os.Stat(root) - if err != nil { - return nil, err - } - if !info.IsDir() { - return nil, fmt.Errorf("%s is not a directory", root) - } - - var paths []string - err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() || !info.Mode().IsRegular() { - // Only files. A symlink or a device in an archive is refused by the host that unpacks - // it, so putting one in would build something that cannot be applied. - if !info.IsDir() && !info.Mode().IsRegular() { - return fmt.Errorf("%s is neither a file nor a directory, and an archive carries "+ - "only those", path) - } - return nil - } - paths = append(paths, path) - return nil - }) - if err != nil { - return nil, err - } - // filepath.Walk is documented to walk in lexical order, so this is belt and braces rather - // than load-bearing — and no test distinguishes it, which is worth saying rather than - // implying otherwise. It stays because the cost is nothing and the failure it guards against - // is silent: an archive whose digest changes because the traversal did. - sort.Strings(paths) - - var out strings.Builder - zipped := gzip.NewWriter(&stringWriter{&out}) - writer := tar.NewWriter(zipped) - for _, path := range paths { - body, err := os.ReadFile(path) - if err != nil { - return nil, err - } - relative, err := filepath.Rel(root, path) - if err != nil { - return nil, err - } - info, err := os.Stat(path) - if err != nil { - return nil, err - } - mode := int64(info.Mode().Perm()) - if err := writer.WriteHeader(&tar.Header{ - Name: filepath.ToSlash(relative), Mode: mode, Size: int64(len(body)), - Typeflag: tar.TypeReg, - // Everything else left at its zero value on purpose — see the note above. - }); err != nil { - return nil, err - } - if _, err := writer.Write(body); err != nil { - return nil, err - } - } - if err := writer.Close(); err != nil { - return nil, err - } - if err := zipped.Close(); err != nil { - return nil, err - } - return []byte(out.String()), nil -} - -type stringWriter struct{ to *strings.Builder } - -func (w *stringWriter) Write(p []byte) (int, error) { return w.to.Write(p) } - -func short(commit string) string { - if len(commit) > 8 { - return commit[:8] - } - return commit -} - -// Said is where the lines Command speaks go, beside the build's own Log: what runs, how long it -// took, and that it failed. Nil prints them to stderr, as a build machine with nobody listening -// should. The machine sets it per build so every line reaches the bus too (novox/hq ADR 0157) — -// the step is "run", and the message is the line as it has always been printed. -var Said Log - -func tell(step, format string, args ...any) { - message := fmt.Sprintf(format, args...) - if Said == nil { - fmt.Fprintf(os.Stderr, " %s\n", message) - return - } - Said(step, message) -} - -// Command is a Runner that actually runs things. -func Command(ctx context.Context, dir, name string, args ...string) (string, error) { - // **Every command is echoed before it runs**, with where. On a build that hangs, the last line - // is exactly the command it is inside — which is the difference between "the builder did - // nothing" and "git clone is waiting on a network that will not answer". Silent on success is - // what made an empty workspace unreadable. - started := timeNow() - tell("run", "$ (%s) %s %s", short(filepath.Base(dir)), name, strings.Join(args, " ")) - cmd := exec.CommandContext(ctx, name, args...) - cmd.Dir = dir - // **Ended whole when the build is** (novox/hq ADR 0219): its own process group, killed as one - // when the context ends, so a build killed by hand leaves no `git` or `docker` child running. - inItsOwnGroup(cmd) - out, err := cmd.CombinedOutput() - if err != nil { - tell("run", "! %s %s failed after %s", name, args[0], since(started)) - // The command's own output is part of what a reader needs — the compiler's error, the - // clone's refusal — and a line per output line keeps it readable on the bus. - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - if line != "" { - tell("output", "%s", line) - } - } - return string(out), fmt.Errorf("%s %s: %w\n%s", - name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) - } - tell("run", "✓ %s %s (%s)", name, firstArg(args), since(started)) - return string(out), nil -} - -func firstArg(args []string) string { - if len(args) == 0 { - return "" - } - return args[0] -} - -var _ io.Writer = (*stringWriter)(nil) - -// standingOn turns the bases a module named into build arguments for what this mesh holds. -// -// **Refused rather than defaulted** (novox/hq issue 044). A module naming a base the mesh has not -// built cannot be built here yet, and the useful sentence names which module is missing — not the -// one a container runtime produces when a recipe's first line refers to an image nobody has. -// -// The order is fixed so two builds of one commit invoke the same command. Returned alongside the -// arguments is every reference they resolved to, which is what the build stood on. -func standingOn(ctx context.Context, manifest catalogue.Manifest, held map[string]string, - mirror func(ctx context.Context, from, repository, former string) (string, error)) ([]string, []string, error) { - if manifest.Build == nil || len(manifest.Build.On) == 0 { - return nil, nil, nil - } - on := append([]catalogue.BuildsOn{}, manifest.Build.On...) - sort.Slice(on, func(i, j int) bool { return on[i].Arg < on[j].Arg }) - - var args, resolved []string - for _, base := range on { - if base.Image != "" { - // A vendor's image, declared (novox/hq 04-ISSUES/064, ADR 0097). Pinned, because a tag - // is what somebody else can move; copied into the mesh's registry, because a build - // that reaches a public registry on its own is a build that works sometimes. - if base.Arg == "" || base.Module != "" || base.Artifact != "" { - return nil, nil, fmt.Errorf( - "%s stands on the image %s, and a base is either a module's artifact or an "+ - "image — never both — read from one build argument", manifest.Module, base.Image) - } - if !strings.Contains(base.Image, "@sha256:") { - return nil, nil, fmt.Errorf( - "%s stands on the image %q, which is not pinned by digest. A tag is what "+ - "somebody else can move; name it as @sha256:…", manifest.Module, base.Image) - } - // **One copy per upstream image, whichever modules stand on it** (novox/hq ADR 0257). Its - // repository is named for the image, not the module; the module's own repository of - // before is offered as a source, so the move to one copy asks upstream for nothing. - repository, err := MirrorRepository(base.Image) - if err != nil { - return nil, nil, fmt.Errorf("%s stands on %s: %w", manifest.Module, base.Image, err) - } - reference, err := mirror(ctx, base.Image, repository, FormerMirrorRepository(manifest.Module, base.Arg)) - if err != nil { - return nil, nil, fmt.Errorf("%s stands on %s: %w", manifest.Module, base.Image, err) - } - args = append(args, "--build-arg", base.Arg+"="+reference) - resolved = append(resolved, reference) - continue - } - if base.Arg == "" || base.Module == "" || base.Artifact == "" { - return nil, nil, fmt.Errorf( - "%s says its build stands on something, and does not say all of what: a base "+ - "needs the module, the artifact, and the build argument the recipe reads it "+ - "from", manifest.Module) - } - key := base.Module + "/" + base.Artifact - reference, has := held[key] - if !has { - return nil, nil, fmt.Errorf( - "%s builds on %s, and this mesh has not built it. Build %s first — every module "+ - "in this toolchain stands on it, so it is the thing to have before anything "+ - "else", manifest.Module, key, base.Module) - } - args = append(args, "--build-arg", base.Arg+"="+reference) - resolved = append(resolved, reference) - } - return args, resolved, nil -} - -// buildArgValues is what each --build-arg of a build was handed: the bases, as this mesh holds them. -func buildArgValues(args []string) []string { - var values []string - for i := 0; i+1 < len(args); i += 2 { - if args[i] == "--build-arg" { - if _, value, has := strings.Cut(args[i+1], "="); has { - values = append(values, value) - } - } - } - return values -} - -// compile runs a module's own code through its toolchain, and says where the result is. -// -// **In the module's own directory, under the path the toolchain expects.** A module is compiled -// where its dependencies resolve upward into the base's own library directory, so what it is -// compiled against is exactly what it will run against — the reason every hand-written Dockerfile -// had to choose a working directory carefully, and the reason none of them has to now. -// publishPackage builds a `package` artifact on a public base image and publishes it to the mesh's -// package registry by version. The credential arrives as an .npmrc file the build was handed -// (novox/hq ADR 0076); it is mounted read-only into the container rather than baked, because a -// package build produces no image to leak it into. The reference returned is name@version, read from -// the module's own package.json — the same two fields npm publishes under. -func publishPackage(ctx context.Context, run Runner, module, dir string, a catalogue.Artifact, - npmrc string, say func(step, format string, args ...any)) (string, error) { - - recipe, ok := packageRecipes[a.Language] - if !ok { - return "", fmt.Errorf( - "a package written in %q cannot be built: no public toolchain is known for it", a.Language) - } - if npmrc == "" { - // A package with nowhere to be published is not built. Said here rather than failing inside - // npm publish with a message about a registry that is simply absent. - return "", fmt.Errorf( - "%s is a package and this build was given no package registry to publish it to", a.Name) - } - - raw, err := os.ReadFile(filepath.Join(dir, "package.json")) - if err != nil { - return "", fmt.Errorf("a package is published by name and version, and %s has no package.json: %w", module, err) - } - var pkg struct { - Name string `json:"name"` - Version string `json:"version"` - } - if err := json.Unmarshal(raw, &pkg); err != nil { - return "", fmt.Errorf("%s's package.json is not readable: %w", module, err) - } - if pkg.Name == "" || pkg.Version == "" { - return "", fmt.Errorf("%s's package.json names no %s to publish under", - module, either(pkg.Name == "", "name", "version")) - } - - const within = "/app/module" - invocation := []string{ - "run", "--rm", - // Host network, so the publish reaches the registry at the address the binding names. - "--network", "host", - "--volume", dir + ":" + within, - // Read-only, so a build cannot alter the credential, and at /root where npm reads it. - "--volume", npmrc + ":/root/.npmrc:ro", - "--workdir", within, - recipe.Base, - "sh", "-c", recipe.Script, - } - if _, err := run(ctx, dir, "docker", invocation...); err != nil { - return "", err - } - return pkg.Name + "@" + pkg.Version, nil -} - -// either names whichever of two fields is the missing one, for a message that says which. -func either(first bool, a, b string) string { - if first { - return a - } - return b -} - -func compile(ctx context.Context, run Runner, tree string, chain Toolchain, - base string, a catalogue.Artifact) (string, error) { - - // Where inside the toolchain the module's source is mounted, and where its output lands. Fixed - // rather than configurable: a module that could move this would be describing its own build. - const within = "/app/modules/module" - - // **Its own output directory, because a module may be several languages at once.** One module - // is one piece of software and can still carry a daemon in one language, tools in another and - // a package in a third (ADR 0040). Compiling them all into one place would have them overwrite - // each other and then be packed together, so each bundle compiles and packs alone. - out := Out(a.Name) - if err := outsideTheModule(tree, filepath.Join(tree, out)); err != nil { - return "", err - } - - // **The output directory exists before the compiler is told about it.** `tsc --outDir` makes - // one; `go build -o` writes a file into a directory and does not create it, failing with a - // message about a path rather than about a build. Made here for every toolchain, because which - // compilers happen to be forgiving is not a thing a reader should have to know - // (novox/hq 04-ISSUES/142). - if err := os.MkdirAll(filepath.Join(tree, out), 0o755); err != nil { - return "", fmt.Errorf("making the output directory for %s: %w", a.Name, err) - } - - invocation := []string{ - "run", "--rm", - "--volume", tree + ":" + within, - "--workdir", within, - base, - } - invocation = append(invocation, chain.Compile...) - // **One `-ldflags`, composed here.** A repeated flag is not a merged one: the Go command takes - // the last and drops the first, so passing the toolchain's flags and then the system stamp as a - // second `-ldflags` produced a binary that knew its system and had lost `-s -w` — half again the - // size, with its debug info (novox/hq 04-ISSUES/161). - // - // What it was built for is the one thing taken from the artifact, and ADR 0142 says why: the - // target is a property of the artifact rather than of the recipe. A host with no system refuses - // every declaration before it applies anything. - linker := append([]string(nil), chain.LinkerFlags...) - if chain.SystemStamp != "" && strings.TrimSpace(a.System) != "" { - linker = append(linker, "-X", chain.SystemStamp+"="+strings.TrimSpace(a.System)) - } - if len(linker) > 0 { - invocation = append(invocation, "-ldflags", strings.Join(linker, " ")) - } - if chain.OutputFlag != "" { - // A compiler pointed at a package is told the file to write, not the directory: the name a - // machine runs it by is not always the name of the package that built it. The host's command - // is `cmd/mesh-host` and every machine runs it as `nox-mesh-host` — so a bundle carrying the - // package's name lands correctly, reports success, and is invisible to whatever looks for it - // (novox/hq 04-ISSUES/142). - target := out - if chain.Unit == UnitPackage { - target = filepath.Join(out, binaryName(a)) - } - invocation = append(invocation, chain.OutputFlag, target) - } - // What to compile. Named by the module rather than discovered, so adding a file does not - // silently change what a build produces. - switch { - case chain.Unit == UnitPackage: - // One directory, compiled whole: the thing the artifact is built `from`. Relative, because - // the compiler runs with the module's own root as its working directory and a package path - // that looked absolute would name one inside the toolchain image. - invocation = append(invocation, "./"+strings.Trim(a.From, "./")) - case len(a.Entrypoints) > 0: - invocation = append(invocation, sourcesFor(a.Entrypoints, out, chain.SourceExt)...) - } - if _, err := run(ctx, tree, "docker", invocation...); err != nil { - return "", err - } - if chain.Dependencies != "" && chain.Bundler == "" { - // **What the bundle runs with, from the image it was compiled in** (Toolchain.Dependencies). - // A second run in the same image rather than a shell wrapped around the compiler: the - // compile line stays a plain command a reader can run by hand, and the copy is one more - // plain command beside it. Refused by name when the image carries no such directory — an - // older toolchain image — because a bundle packed without its dependencies starts nowhere - // and says so three layers away from here. - copying := []string{ - "run", "--rm", - "--volume", tree + ":" + within, - "--workdir", within, - base, - "sh", "-c", - `test -d "$1" || { echo "the toolchain image carries no $1: it predates the mesh shipping a bundle's dependencies, rebuild $2 first" >&2; exit 1; }; cp -a "$1/." "$3/"`, - "dependencies", chain.Dependencies, chain.Base, out, - } - if _, err := run(ctx, tree, "docker", copying...); err != nil { - return "", fmt.Errorf("copying the %s dependencies a bundle runs with: %w", chain.Language, err) - } - } - return filepath.Join(tree, out), nil -} - -// sourcesFor turns compiled entrypoints back into what to compile. -// -// A module names what a tool host should LOAD — compiled paths under the bundle's root — because -// that is the thing anything else needs to know. What to compile is the same list with the -// language's own extension, which is the toolchain's business rather than the module's. -func sourcesFor(entrypoints []string, out, ext string) []string { - sources := make([]string, 0, len(entrypoints)) - for _, e := range entrypoints { - // An entrypoint is named as it will be FOUND — a path inside the unpacked bundle — so the - // source is the same path with the output directory taken off the front and the language's - // own extension on the end. **The extension is the toolchain's**, where it used to be the - // literal `.ts`: one language's file extension written into the code that serves every - // language is a wall the next one hits (novox/hq 04-ISSUES/142). - at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/") - sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+ext) - } - return sources -} - -func timeNow() time.Time { return time.Now() } -func since(t time.Time) string { return time.Since(t).Round(time.Millisecond).String() } - -// undeclaredFetches is every image a recipe reaches for that is neither a declared build argument -// nor one of its own stages nor `scratch`, in two lists: the bases it starts `FROM`, and the images -// it `COPY --from`s out of — a vendor's tool, the case novox/hq 04-ISSUES/064 is about. -func undeclaredFetches(recipe string, declared map[string]bool) (bases, copies []string) { - stages := map[string]bool{} - seen := map[string]bool{} - var out *[]string - note := func(ref string) { - ref = strings.TrimSpace(ref) - switch { - case ref == "" || ref == "scratch" || stages[strings.ToLower(ref)]: - return - case strings.HasPrefix(ref, "$"): - name := strings.Trim(strings.TrimPrefix(ref, "$"), "{}") - if cut := strings.IndexAny(name, ":-"); cut >= 0 { - name = name[:cut] - } - if !declared[name] { - if !seen[ref] { - seen[ref] = true - *out = append(*out, ref+" (a build argument the manifest does not declare)") - } - } - return - } - // A stage referenced by number (COPY --from=0) is its own recipe's. - if _, err := strconv.Atoi(ref); err == nil { - return - } - if !seen[ref] { - seen[ref] = true - *out = append(*out, ref) - } - } - for _, line := range instructions(recipe) { - fields := strings.Fields(line) - switch strings.ToUpper(fields[0]) { - case "FROM": - // FROM [--platform=…] [AS ] - out = &bases - var ref string - for i := 1; i < len(fields); i++ { - if strings.HasPrefix(fields[i], "--") { - continue - } - ref = fields[i] - if i+2 < len(fields) && strings.EqualFold(fields[i+1], "AS") { - stages[strings.ToLower(fields[i+2])] = true - } - break - } - note(ref) - case "COPY", "ADD": - out = &copies - for _, f := range fields[1:] { - if strings.HasPrefix(f, "--from=") { - note(strings.TrimPrefix(f, "--from=")) - } - } - case "RUN": - // RUN --mount=type=bind,from=,… reaches for an image exactly as COPY --from does. - out = &copies - for _, f := range fields[1:] { - if !strings.HasPrefix(f, "--mount=") { - continue - } - for _, opt := range strings.Split(strings.TrimPrefix(f, "--mount="), ",") { - if from, found := strings.CutPrefix(opt, "from="); found { - note(from) - } - } - } - } - } - return bases, copies -} - -// instructions is a recipe as its instructions, one per line: continuations joined, comments and -// blank lines dropped, and heredoc bodies (`COPY <= 0 { - // `< 0 { - heredoc = strings.Trim(strings.TrimPrefix(word[0], "-"), `'"`) - } - } - flush() - } - flush() - return out -} - -// readBy is every repository other than the module's own that this build's recipes read source from: the rule -// build-agent and the controller share (internal/buildreads). -func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext { return buildreads.Of(manifest) } - -// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of -// the package it is built from, which is what a compiler would have chosen anyway. -func binaryName(a catalogue.Artifact) string { - if name := catalogue.BinaryOf(a); name != "" { - return name - } - if name := strings.TrimSpace(a.Binary); name != "" { - return name - } - if from := strings.Trim(a.From, "./"); from != "" { - return filepath.Base(from) - } - return a.Name -} - -// launcherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is -// started as index.serve.mjs (novox/hq ADR 0193). An ES module by its own extension, whatever the -// bundle's package.json says. -const launcherSuffix = ".serve.mjs" - -// writeLaunchers writes, beside every entrypoint of a TypeScript bundle, an executable that -// imports the entrypoint and serves what it registered over MCP on stdio — through the bundle's -// own copy of the SDK, so registering and serving meet in one registry (novox/hq ADR 0193). Its -// answer is each entrypoint's launcher, by entrypoint, relative to the bundle's root; nothing for -// a language whose build is already executable. -func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[string]string, error) { - if chain.Language != "typescript" { - return nil, nil - } - out := map[string]string{} - for _, entry := range a.Entrypoints { - if !strings.HasSuffix(entry, ".js") { - continue - } - launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix - body := "#!/usr/bin/env node\n" + - "// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" + - "// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" + - "import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" + - "await import(\"./" + filepath.Base(entry) + "\");\n" + - "await serveRegisteredOverStdio();\n" - path := filepath.Join(root, filepath.FromSlash(launcher)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return nil, err - } - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - return nil, err - } - // WriteFile honours the umask; the mode a machine unpacks is the one packed, so it is set. - if err := os.Chmod(path, 0o755); err != nil { - return nil, err - } - out[entry] = launcher - } - return out, nil -} - -// bundledSuffix is where a bundle's one-file output is written, beside what the compiler wrote. -const bundledSuffix = ".bundled" - -// bundled makes every entrypoint and every launcher of a compiled bundle ONE file, in the toolchain -// image's bundler, and answers the directory to pack (novox/hq ADR 0193). -// -// **What a launched bundle runs is what it imports, and nothing else.** Every served bundle is its -// own process, so it carries its own copy of the SDK and its own dependencies inlined — the -// toolchain's whole node_modules no longer travels in every bundle. An entrypoint a process runs by -// name (`node daemon/index.js`) is bundled in place under its own name; a launcher keeps its name -// and its first line, and stays executable. A package the bundler cannot inline is named by the -// artifact (`external`), kept as an import, and only then is the toolchain's runtime directory -// copied beside the files. CommonJS inlined into an ES module still finds `require`. -func bundled(ctx context.Context, run Runner, tree string, chain Toolchain, base string, - a catalogue.Artifact, launchers map[string]string) (string, error) { - const within = "/app/modules/module" - out, final := Out(a.Name), Out(a.Name)+bundledSuffix - if err := outsideTheModule(tree, filepath.Join(tree, final)); err != nil { - return "", err - } - if err := os.RemoveAll(filepath.Join(tree, final)); err != nil { - return "", err - } - if err := os.MkdirAll(filepath.Join(tree, final), 0o755); err != nil { - return "", err - } - common := []string{"--bundle", "--platform=node", "--format=esm", "--target=node22", - "--outbase=" + out, "--outdir=" + final, "--log-level=warning", - "--banner:js=import { createRequire as __meshRequire } from 'node:module'; const require = __meshRequire(import.meta.url);"} - for _, x := range a.External { - common = append(common, "--external:"+x) - } - var plain []string - for _, e := range a.Entrypoints { - if strings.HasSuffix(e, ".js") { - plain = append(plain, out+"/"+e) - } - } - var launch []string - for _, l := range sortedValues(launchers) { - launch = append(launch, out+"/"+l) - } - // Refused by name in an image that predates the bundler, as the dependencies copy is: a bundle - // packed without it would carry nothing it imports. Run as itself: npm installs esbuild's native - // binary in place of its script, which `node` cannot run. - guard := `test -x "$0" || { echo "the toolchain image carries no bundler at $0: it predates one-file bundles, rebuild mesh-tools first" >&2; exit 1; }; exec "$0" "$@"` - step := func(entries []string, extra ...string) error { - if len(entries) == 0 { - return nil - } - invocation := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base, - "sh", "-c", guard, chain.Bundler} - invocation = append(invocation, entries...) - invocation = append(invocation, common...) - invocation = append(invocation, extra...) - _, err := run(ctx, tree, "docker", invocation...) - return err - } - if err := step(plain); err != nil { - return "", err - } - if err := step(launch, "--out-extension:.js=.mjs"); err != nil { - return "", err - } - // Plain `.js` output is an ES module; said once, as the runtime directory used to say it. - if err := os.WriteFile(filepath.Join(tree, final, "package.json"), []byte(`{"type":"module","private":true}`+"\n"), 0o644); err != nil { - return "", err - } - for _, l := range launchers { - path := filepath.Join(tree, final, filepath.FromSlash(l)) - if _, err := os.Stat(path); err == nil { - if err := os.Chmod(path, 0o755); err != nil { - return "", err - } - } - } - if len(a.External) > 0 && chain.Dependencies != "" { - copying := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base, - "sh", "-c", `cp -a "$0/node_modules" "$1/"`, chain.Dependencies, final} - if _, err := run(ctx, tree, "docker", copying...); err != nil { - return "", fmt.Errorf("copying the packages %s keeps external: %w", a.Name, err) - } - } - return filepath.Join(tree, final), nil -} - -func sortedValues(m map[string]string) []string { - out := make([]string, 0, len(m)) - for _, v := range m { - out = append(out, v) - } - sort.Strings(out) - return out -} diff --git a/internal/builder/builder_integration_test.go b/internal/builder/builder_integration_test.go deleted file mode 100644 index 31447aec..00000000 --- a/internal/builder/builder_integration_test.go +++ /dev/null @@ -1,362 +0,0 @@ -//go:build integration - -package builder - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -func TestABuildProducesAManifestThePinsAreIn(t *testing.T) { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/theme.conf": "dark", - }) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if got.Commit != "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff" { - t.Fatalf("the commit was not recorded: %q", got.Commit) - } - if got.Manifest.Resources[0]["image"] == nil { - t.Fatalf("the image was not pinned: %v", got.Manifest.Resources[0]) - } - digest, _ := got.Manifest.Resources[1]["digest"].(string) - if !strings.HasPrefix(digest, "sha256:") { - t.Fatalf("the archive was not pinned: %v", got.Manifest.Resources[1]) - } -} - -func TestTwoBuildsOfOneCommitProduceOneDigest(t *testing.T) { - // Or nothing downstream can tell "this changed" from "this was built again", and every - // rebuild looks like a change to every machine holding it. - var digests []string - for i := 0; i < 2; i++ { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/a.conf": "one", "files/b.conf": "two", - }) - // A year apart, so a packer carrying timestamps cannot accidentally agree. - r.stamped = time.Date(2020+i, time.March, 3, 4, 5, 6, 0, time.UTC) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - for _, b := range got.Built { - if b.Kind == catalogue.ArtifactArchive { - digests = append(digests, b.Digest) - } - } - } - if digests[0] != digests[1] { - t.Fatalf("two builds of one commit produced %s and %s", digests[0], digests[1]) - } -} - -func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) { - // Half a module in the store under a digest the mesh never records is reachable, - // unreferenced, and indistinguishable from something in use. - r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch"}) - // `files` is missing, so packing the archive fails — after the image would have been pushed. - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatal("a build with a missing input succeeded") - } - if len(r.archives) != 0 { - t.Fatalf("an archive was published by a failed build: %v", r.archives) - } -} - -func TestARepositoryWithNoManifestSaysSo(t *testing.T) { - workspace := t.TempDir() - r := &recorded{contents: map[string]string{"README.md": "nothing to see"}} - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatal("a repository with nothing saying what it is was built") - } - if !strings.Contains(err.Error(), ManifestName) { - t.Fatalf("the failure does not name what is missing: %v", err) - } -} - -func TestAModuleThatBuildsNothingStillProducesAManifest(t *testing.T) { - // Most of what a person installs is configuration. - r, workspace := aRepository(t, `{"module":"shell","version":"1","resources":[ - {"id":"rc","type":"file","path":"/etc/zsh/zshrc","content":"setopt"}]}`, nil) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if len(got.Built) != 0 { - t.Fatalf("something was built: %v", got.Built) - } - if got.Manifest.Module != "shell" || len(got.Manifest.Resources) != 1 { - t.Fatalf("got %+v", got.Manifest) - } - for _, line := range r.ran { - if strings.HasPrefix(line, "docker") { - t.Fatalf("docker was run for a module that builds nothing: %q", line) - } - } -} - -func TestTheTreeIsFreshEveryTime(t *testing.T) { - // A build that reuses a working tree can succeed because of something a previous build left - // behind, and that is a build nobody can reproduce. - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/a": "b", - }) - leftover := filepath.Join(workspace, "source", "files", "from-last-time") - if err := os.MkdirAll(filepath.Dir(leftover), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(leftover, []byte("stale"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(leftover); err == nil { - t.Fatal("a previous build's file survived into this one") - } -} - -func TestABuildThatCannotPushFails(t *testing.T) { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/a": "b", - }) - r.failPush = true - if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err == nil { - t.Fatal("a build that could publish nothing reported success") - } -} - -func TestAnUpstreamImageIsMirroredRatherThanBuilt(t *testing.T) { - // A module usually runs software it did not write. Naming the upstream reference directly - // would need every machine to reach a public registry, and would pin to a tag somebody else - // can move. - const mirrors = `{"module":"postgres","version":"1", - "build":{"artifacts":[{"name":"store","kind":"upstream","from":"postgres:17-alpine"}]}, - "resources":[{"id":"db","type":"container","name":"mesh-postgres","artifact":"store"}]}` - - r, workspace := aRepository(t, mirrors, nil) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - // Pulled, not built. - var pulled, built bool - for _, line := range r.ran { - if strings.HasPrefix(line, "docker pull postgres:17-alpine") { - pulled = true - } - if strings.HasPrefix(line, "docker build") { - built = true - } - } - if !pulled { - t.Fatalf("the upstream image was not fetched: %v", r.ran) - } - if built { - t.Fatalf("something was built for an image that is mirrored: %v", r.ran) - } - // And the resource names what this registry serves, pinned by the digest it assigned. - image, _ := got.Manifest.Resources[0]["image"].(string) - if !strings.Contains(image, "@sha256:") { - t.Fatalf("the mirrored image is not pinned by digest: %q", image) - } - if strings.Contains(image, "17-alpine") { - t.Fatalf("the resource still names the upstream tag: %q", image) - } -} - -func TestAModuleIsBuiltFromItsPathWithinTheRepository(t *testing.T) { - r := &recorded{contents: map[string]string{ - "README.md": "this repository holds several modules", - "modules/shell/" + ManifestName: withBoth, - "modules/shell/Dockerfile": "FROM scratch", - "modules/shell/files/theme.conf": "dark", - // A second module beside it, so what is built is chosen by the path rather than by - // happening to be the only manifest in the clone. - "modules/other/" + ManifestName: `{"module":"other","version":"1"}`, - }} - got, err := Build(context.Background(), r.run, r, - "https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if got.Manifest.Module != "meshboard" { - t.Fatalf("built %q, which is not the module at the path asked for", got.Manifest.Module) - } - if got.Manifest.Resources[0]["image"] == nil { - t.Fatalf("the image was not pinned: %v", got.Manifest.Resources[0]) - } -} - -func TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) { - for _, escaping := range []string{"../../etc", "/etc"} { - r := &recorded{contents: map[string]string{ManifestName: withBoth}} - _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/x.git", escaping, "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatalf("%q was accepted as a module's path", escaping) - } - if !strings.Contains(err.Error(), "leaves the repository") && - !strings.Contains(err.Error(), "absolute path") { - t.Fatalf("the refusal of %q does not say why: %v", escaping, err) - } - } -} - -func TestAnArtifactWithItsOwnContextIsBuiltFromThere(t *testing.T) { - const withContext = `{"module":"route-proxy","version":"1", - "build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile", - "context":{"repository":"https://forge.invalid/source.git","ref":"main"}}]}}` - r := &recorded{ - contents: map[string]string{ - ManifestName: withContext, - // The recipe lives with the packaging, not the source — read from here regardless of - // where the build context comes from. FROM scratch declares no base, so what is under - // test — where the context comes from — is not entangled with ADR 0097's own checks. - "Dockerfile": "FROM scratch\nCOPY go.mod ./\n", - }, - secondary: map[string]map[string]string{ - // go.mod exists only in the second repository. A build context taken from the wrong - // place would never find it, which a real docker build would refuse on — the fake - // does not read files, so what is checked below is that the build was even pointed - // at the right place, not that COPY would have succeeded. - "https://forge.invalid/source.git": {"go.mod": "module route-proxy\n"}, - }, - } - _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - - var clonedSource bool - for _, line := range r.ran { - if strings.HasPrefix(line, "git clone") && strings.Contains(line, "https://forge.invalid/source.git") { - clonedSource = true - } - } - if !clonedSource { - t.Fatalf("the artifact's own context was never cloned: %v", r.ran) - } - - buildIndex := -1 - for i, line := range r.ran { - if strings.HasPrefix(line, "docker build ") { - buildIndex = i - } - } - if buildIndex == -1 { - t.Fatal("no docker build was run") - } - build := r.ran[buildIndex] - buildDir := r.dirs[buildIndex] - - if !strings.Contains(buildDir, "context-server") { - t.Errorf("docker build ran from %q, not the artifact's own cloned context", buildDir) - } - recipe := strings.SplitN(strings.SplitN(build, "-f ", 2)[1], " ", 2)[0] - if !filepath.IsAbs(recipe) { - t.Errorf("the recipe %q is not an absolute path, so it is read relative to whatever "+ - "directory the build context moved to rather than where it actually is", recipe) - } - if !strings.HasSuffix(recipe, string(filepath.Separator)+"Dockerfile") { - t.Errorf("the recipe is not the module's own Dockerfile: %q", recipe) - } - if !strings.HasSuffix(build, " .") { - t.Errorf("the build was not given a context: %s", build) - } -} - -func TestABuildOffersTheForgesCredentialThroughGitsOwnStore(t *testing.T) { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/theme.conf": "dark", - }) - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", - workspace, nil, Npmrc{}, - GitCredential{URL: "http://mesh_novox_builder:sw0rdfi5h@forge.invalid:20000"}, nil) - if err != nil { - t.Fatal(err) - } - clone := r.ran[0] - stored := storeNamedIn(t, clone) - for _, line := range r.ran { - if strings.Contains(line, "sw0rdfi5h") { - t.Fatalf("the secret is in a command line, readable by anything that can list processes: %s", line) - } - } - // Outside the workspace a check's container mounts, and gone once the build is (novox/hq issue 462); what - // it held while git read it is checked with the check's clones (TestACheckContainerSeesNoForgeCredential). - if withinDir(workspace, stored) { - t.Fatalf("the credential store %s is inside the workspace %s", stored, workspace) - } - if _, err := os.Stat(stored); !os.IsNotExist(err) { - t.Fatalf("the credential store outlives the build: %v", err) - } - if _, err := os.Stat(filepath.Join(workspace, "git-credentials")); !os.IsNotExist(err) { - t.Fatal("a credential file is left in the workspace") - } -} - -func TestABuildWithNoCredentialClonesExactlyAsBefore(t *testing.T) { - r, workspace := aRepository(t, withBoth, map[string]string{ - "Dockerfile": "FROM scratch", "files/theme.conf": "dark", - }) - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", - workspace, nil, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(r.ran[0], "git clone --quiet ") { - t.Fatalf("a credential-less clone grew flags: %s", r.ran[0]) - } - if _, err := os.Stat(filepath.Join(workspace, "git-credentials")); !os.IsNotExist(err) { - t.Fatal("a credential file was written with no credential to put in it") - } -} - -func TestAContextCloneCarriesTheSameCredentialStore(t *testing.T) { - const withContext = `{"module":"route-proxy","version":"1", - "build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile", - "context":{"repository":"https://forge.invalid/source.git","ref":"main"}}]}}` - r := &recorded{ - contents: map[string]string{ - ManifestName: withContext, - "Dockerfile": "FROM scratch\nCOPY go.mod ./\n", - }, - secondary: map[string]map[string]string{ - "https://forge.invalid/source.git": {"go.mod": "module route-proxy\n"}, - }, - } - workspace := t.TempDir() - _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/catalogue.git", "", "", workspace, nil, Npmrc{}, - GitCredential{URL: "https://builder:s3cret@forge.invalid"}, nil) - if err != nil { - t.Fatal(err) - } - stored := storeNamedIn(t, r.ran[0]) - var contextClone string - for _, line := range r.ran { - if strings.Contains(line, "clone") && strings.Contains(line, "source.git") { - contextClone = line - } - } - if contextClone == "" { - t.Fatalf("the context was never cloned: %v", r.ran) - } - if !strings.Contains(contextClone, "credential.helper=store --file="+stored) { - t.Fatalf("the context clone does not name the credential store: %s", contextClone) - } -} diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go deleted file mode 100644 index 0151585d..00000000 --- a/internal/builder/builder_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package builder - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// A repository becoming artifacts the mesh can pin. -// -// git and docker are injected rather than run, because what is under test is the ORDER and the -// refusals — that nothing is published until everything is built, that a build reads only its own -// tree, that two builds of one commit produce one digest. Running docker here would test docker. - -type recorded struct { - ran []string - // dirs is the directory each entry in ran was run from, same index — so a test can ask not - // only what ran but where. - dirs []string - images map[string]string - archives map[string]string - failPush bool - // contents is what a clone of this repository lands, so the fake clone can restore the tree - // Build deliberately removes first. - contents map[string]string - // secondary is what a clone of a repository OTHER than the one under test lands, keyed by - // that repository's URL — an artifact's own build context, cloned apart from the module. - secondary map[string]map[string]string - // stamped is the modification time the clone gives every file. Set differently between two - // builds of one commit, because otherwise both land in the same second and a packer that - // carried timestamps would still produce one digest — which is a test that passes for a - // reason that has nothing to do with what it claims. - stamped time.Time - // tree is what git names as the tree of the module's directory; empty answers as for a commit. - tree string - // builtFrom is the files (slash-separated, relative) of the folder the last `docker build` ran in, read as it ran: - // the build removes that folder when it ends (novox/hq issue 1834). - builtFrom []string -} - -func (r *recorded) run(_ context.Context, dir, name string, args ...string) (string, error) { - line := name + " " + strings.Join(args, " ") - r.ran = append(r.ran, line) - r.dirs = append(r.dirs, dir) - // A clone may carry `-c` configuration in front of the verb — the credential store — so the - // verb is found rather than assumed first. - isClone := false - for _, a := range args { - if a == "clone" { - isClone = true - break - } - } - if name == "docker" && len(args) > 0 && args[0] == "build" { - r.builtFrom = nil - _ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { - if err == nil && !d.IsDir() { - rel, _ := filepath.Rel(dir, p) - r.builtFrom = append(r.builtFrom, filepath.ToSlash(rel)) - } - return nil - }) - } - switch { - case name == "git" && isClone: - repository := args[len(args)-2] - tree := args[len(args)-1] - if err := os.MkdirAll(tree, 0o755); err != nil { - return "", err - } - lands := r.contents - if by, is := r.secondary[repository]; is { - lands = by - } - for path, body := range lands { - full := filepath.Join(tree, path) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - return "", err - } - if err := os.WriteFile(full, []byte(body), 0o644); err != nil { - return "", err - } - if !r.stamped.IsZero() { - if err := os.Chtimes(full, r.stamped, r.stamped); err != nil { - return "", err - } - } - } - return "", nil - case name == "git" && len(args) > 1 && args[0] == "rev-parse" && strings.HasPrefix(args[1], "HEAD:") && r.tree != "": - return r.tree + "\n", nil - case name == "git" && len(args) > 0 && args[0] == "rev-parse": - return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil - case name == "git" && len(args) > 1 && args[0] == "rev-list" && args[1] == "--count": - return "42\n", nil - } - return "", nil -} - -func (r *recorded) PublishImage(_ context.Context, localTag, repository string) (string, error) { - if r.failPush { - return "", os.ErrPermission - } - if r.images == nil { - r.images = map[string]string{} - } - r.images[repository] = localTag - return "registry.invalid/" + repository + "@sha256:" + strings.Repeat("a", 64), nil -} - -func (r *recorded) PublishArchive(_ context.Context, repository string, body []byte, digest string) (string, error) { - if r.failPush { - return "", os.ErrPermission - } - if r.archives == nil { - r.archives = map[string]string{} - } - r.archives[repository] = digest - _ = body - return "https://store.invalid/" + repository, nil -} - -// aRepository is a workspace whose clone lands a manifest and some files. -func aRepository(t *testing.T, manifest string, files map[string]string) (*recorded, string) { - t.Helper() - contents := map[string]string{ManifestName: manifest} - for name, body := range files { - contents[name] = body - } - return &recorded{contents: contents}, t.TempDir() -} - -const withBoth = `{"module":"meshboard","version":"1", - "build":{"artifacts":[ - {"name":"server","kind":"image","from":"Dockerfile"}, - {"name":"look","kind":"archive","from":"files"}]}, - "resources":[ - {"id":"svc","type":"container","name":"meshboard","artifact":"server"}, - {"id":"theme","type":"archive","path":"/opt/meshboard","artifact":"look"}]}` - -func TestAnUpstreamImageWithNoTagIsRefused(t *testing.T) { - // What gets mirrored would be whatever `latest` means today, and a module pinned to that is - // not pinned. - _, err := catalogue.ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"upstream","from":"postgres"}]}}`)) - if err == nil { - t.Fatal("an untagged upstream reference was accepted") - } - if !strings.Contains(err.Error(), "no tag or digest") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - -func TestAnUpstreamReferenceIsNotAPathInTheRepository(t *testing.T) { - // The rule that a build reads only its own repository must not refuse every reference with a - // registry host in it. - if _, err := catalogue.ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"upstream","from":"registry.example/library/postgres:17"}]}}`)); err != nil { - t.Fatalf("a perfectly ordinary upstream reference was refused: %v", err) - } -} - -// **A module is a repository and a path within it** (novox/hq ADR 0069). The catalogue holds its -// modules one to a directory and the system this replaces has always built one that way, so a -// builder that could only read a repository's root could build none of what exists. - -// A build reads only its own tree. A path climbing out of the clone would otherwise let a build -// read — and an archive artifact publish — whatever the build machine happens to hold, which is -// the one thing a machine that builds other people's repositories must not do. - -// **Packaging and source are allowed to live apart** — a module that ships only the recipe for -// source that lives in a second repository (the reference route-proxy, packaged in the catalogue -// but built from mesh-controller's own repository) names where that source actually is, rather -// than vendoring a second copy the two could drift from. - -// The forge credential is offered through git's own credential store — a file, never argv — and -// git decides when it applies. What is checked: the clone names the store, the secret never -// appears in a command line, and the file holds exactly the URL at 0600. - -// storeNamedIn is the credential store a git command line offers. -func storeNamedIn(t *testing.T, line string) string { - t.Helper() - _, after, ok := strings.Cut(line, "credential.helper=store --file=") - if !ok { - t.Fatalf("the clone does not name the credential store: %s", line) - } - return strings.Fields(after)[0] -} - -// Without a credential, a clone is exactly the invocation it always was, and no credential file -// appears — the builder a mesh of public repositories runs is unchanged. - -// An artifact's own context is cloned with the same offer: a private module whose context is a -// second private repository on the same forge builds, and the secret still never reaches argv. diff --git a/internal/builder/bundle_integration_test.go b/internal/builder/bundle_integration_test.go deleted file mode 100644 index 80a69e81..00000000 --- a/internal/builder/bundle_integration_test.go +++ /dev/null @@ -1,166 +0,0 @@ -//go:build integration - -package builder - -import ( - "context" - "strings" - "testing" -) - -func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) { - r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"}) - held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)} - - got, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatalf("a module with a language and no Dockerfile did not build: %v", err) - } - - // Compiled in the toolchain the mesh chose, not in one the module named. - var compiled string - for _, line := range r.ran { - if strings.HasPrefix(line, "docker run") { - compiled = line - } - } - if compiled == "" { - t.Fatalf("nothing was compiled:\n%s", strings.Join(r.ran, "\n")) - } - if !strings.Contains(compiled, "mesh-tools/build@sha256:") { - t.Fatalf("the compile did not run in the mesh's own toolchain: %s", compiled) - } - if strings.Contains(strings.Join(r.ran, "\n"), "docker build") { - t.Fatalf("a bundle invoked a Dockerfile build, which is the thing it exists to avoid:\n%s", - strings.Join(r.ran, "\n")) - } - - // And pinned by a digest of what came out, like any other artifact. - digest, _ := got.Manifest.Resources[0]["digest"].(string) - if !strings.HasPrefix(digest, "sha256:") { - t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0]) - } - - // **One file per entrypoint and launcher, in the toolchain's bundler** (novox/hq ADR 0193). A - // second run in the same toolchain image bundles each into the artifact's bundled output, the SDK - // inlined, refusing by name in an image that predates the bundler; and the toolchain's - // node_modules is no longer copied into a bundle that keeps nothing external. - var bundling []string - for _, line := range r.ran { - if strings.HasPrefix(line, "docker run") && strings.Contains(line, "esbuild") { - bundling = append(bundling, line) - } - } - if len(bundling) != 2 { - t.Fatalf("want one bundling run for the entrypoints and one for the launchers:\n%s", strings.Join(r.ran, "\n")) - } - for _, want := range []string{"mesh-tools/build@sha256:", "predates one-file bundles", "--bundle", "--format=esm", - "--platform=node", "--outdir=" + Out("code") + ".bundled", Out("code") + "/index.js"} { - if !strings.Contains(bundling[0], want) { - t.Errorf("the entrypoints' bundling lacks %q: %s", want, bundling[0]) - } - } - if !strings.Contains(bundling[1], Out("code")+"/index.serve.mjs") || !strings.Contains(bundling[1], "--out-extension:.js=.mjs") { - t.Errorf("the launcher is not bundled under its own name: %s", bundling[1]) - } - if strings.Contains(strings.Join(r.ran, "\n"), "/app/runtime") { - t.Errorf("the toolchain's node_modules was copied into a bundle that keeps nothing external:\n%s", strings.Join(r.ran, "\n")) - } - if strings.Index(strings.Join(r.ran, "\n"), "--outDir") > strings.Index(strings.Join(r.ran, "\n"), "esbuild") { - t.Fatal("the bundler ran before the compile wrote its output") - } -} - -func TestABundleKeepingAPackageExternalCarriesTheToolchainsModules(t *testing.T) { - manifest := strings.Replace(aBundle, `"entrypoints":["index.js"]`, `"entrypoints":["index.js"],"external":["sharp"]`, 1) - r, workspace := aRepository(t, manifest, map[string]string{"index.ts": "console.log(1)"}) - held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)} - if _, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil); err != nil { - t.Fatal(err) - } - all := strings.Join(r.ran, "\n") - if !strings.Contains(all, "--external:sharp") || !strings.Contains(all, "/app/runtime") { - t.Errorf("an external package was not kept as an import with the toolchain's modules beside it:\n%s", all) - } -} - -func TestABundleWhoseToolchainIsNotHeldIsRefusedFirst(t *testing.T) { - r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"}) - - _, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatal("a bundle was built with no toolchain to compile it in") - } - if !strings.Contains(err.Error(), "mesh-tools") { - t.Fatalf("the refusal does not name what has to be built first: %v", err) - } - for _, line := range r.ran { - if strings.HasPrefix(line, "docker run") { - t.Fatalf("a compile was attempted before the refusal: %s", line) - } - } -} - -func TestABundleInAnUnknownLanguageIsRefused(t *testing.T) { - manifest := strings.Replace(aBundle, `"language":"typescript"`, `"language":"cobol"`, 1) - r, workspace := aRepository(t, manifest, map[string]string{"index.ts": "x"}) - - _, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, - map[string]string{"mesh-tools/build": "registry.invalid/x@sha256:" + strings.Repeat("c", 64)}, Npmrc{}, GitCredential{}, nil) - if err == nil { - t.Fatal("a language nothing can compile was accepted") - } - if !strings.Contains(err.Error(), "typescript") { - t.Fatalf("the refusal does not say what would have worked: %v", err) - } -} - -func TestTwoBundlesInOneModuleArePackedSeparately(t *testing.T) { - const two = `{"module":"greeter","version":"1", - "build":{"artifacts":[ - {"name":"daemon","kind":"bundle","language":"typescript","entrypoints":["index.js"]}, - {"name":"tools","kind":"bundle","language":"typescript","entrypoints":["index.js"]}]}, - "resources":[ - {"id":"a","type":"archive","path":"/opt/greeter/daemon","artifact":"daemon"}, - {"id":"b","type":"archive","path":"/opt/greeter/tools","artifact":"tools"}]}` - - r, workspace := aRepository(t, two, map[string]string{"index.ts": "console.log(1)"}) - held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)} - - got, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil) - if err != nil { - t.Fatalf("a module with two bundles did not build: %v", err) - } - - // Compiled into two different places. Only the compile lines: the copy of each bundle's - // dependencies names the same directory again, deliberately. - var outputs []string - for _, line := range r.ran { - if !strings.Contains(line, "--outDir") { - continue - } - for _, part := range strings.Fields(line) { - if strings.HasPrefix(part, ".mesh-build/") { - outputs = append(outputs, part) - } - } - } - if len(outputs) != 2 || outputs[0] == outputs[1] { - t.Fatalf("two bundles did not get their own output directories: %v", outputs) - } - - // And published as two artifacts, each with its own digest. - if len(r.archives) != 2 { - t.Fatalf("expected two archives published, got %v", r.archives) - } - first, _ := got.Manifest.Resources[0]["digest"].(string) - second, _ := got.Manifest.Resources[1]["digest"].(string) - if first == "" || second == "" { - t.Fatalf("a bundle was not pinned: %v", got.Manifest.Resources) - } -} diff --git a/internal/builder/bundle_test.go b/internal/builder/bundle_test.go deleted file mode 100644 index ccabe300..00000000 --- a/internal/builder/bundle_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package builder - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -const aBundle = `{"module":"greeter","version":"1", - "build":{"artifacts":[ - {"name":"code","kind":"bundle","language":"typescript","entrypoints":["index.js"]}]}, - "resources":[ - {"id":"files","type":"archive","path":"/opt/greeter","artifact":"code"}]}` - -// compiling is a runner that behaves like a toolchain: when asked to compile, it leaves output -// where the toolchain says output lands. Without this the pack step has nothing to pack, and the -// test would be asserting on a failure rather than on a build. -type compiling struct{ *recorded } - -func (c compiling) run(ctx context.Context, dir, name string, args ...string) (string, error) { - out, err := c.recorded.run(ctx, dir, name, args...) - if name != "docker" || len(args) == 0 || args[0] != "run" { - return out, err - } - // **Writes where it was TOLD to**, rather than to a fixed directory. A fake that always wrote - // to one place would pass whether or not the builder gave each artifact its own — which is the - // thing being tested. - where := "" - for i, a := range args { - if a == "--outDir" && i+1 < len(args) { - where = args[i+1] - } - } - if where == "" { - return out, err - } - made := filepath.Join(dir, where) - if err := os.MkdirAll(made, 0o755); err != nil { - return "", err - } - if err := os.WriteFile(filepath.Join(made, "index.js"), []byte("console.log(1)"), 0o644); err != nil { - return "", err - } - return out, err -} - -// **A module says what it is written in, and needs no Dockerfile.** This is the whole point of the -// bundle recipe: the same module previously needed a hand-written recipe repeating an incantation -// that is easy to get wrong in ways that fail somewhere else. - -// A bundle naming packages it keeps external is bundled with them as imports, and carries the -// toolchain's node_modules for them — the one case it still does. - -// A language whose bundle carries its own dependencies copies nothing in: a Go binary is static. -func TestOnlyALanguageWithARuntimeDirectoryCopiesDependenciesIn(t *testing.T) { - ts, _ := ToolchainFor("typescript") - if ts.Dependencies != "/app/runtime" { - t.Fatalf("typescript bundles run with %q", ts.Dependencies) - } - for _, language := range []string{"go", "python"} { - chain, _ := ToolchainFor(language) - if chain.Dependencies != "" { - t.Fatalf("%s copies %q into every bundle, and its bundles carry their own", language, chain.Dependencies) - } - } -} - -// **Refused before anything is built, naming what to build first.** A base the mesh has not built -// is not a compile that fails on its first line — it is a question somebody can answer, and saying -// it early is the difference between a fixable message and one about a missing image. - -// A language the mesh does not build is refused the same way, and names what it can build. - -// **One module, two bundles, and neither packs the other.** -// -// The case that matters for real modules: a module is one piece of software and may still carry a -// daemon in one language and tools in another (ADR 0040). An earlier version of this compiled -// every bundle into the toolchain's single output directory, so two of them would overwrite each -// other and then be packed together — one artifact containing both, twice. diff --git a/internal/builder/check.go b/internal/builder/check.go deleted file mode 100644 index b9add5d2..00000000 --- a/internal/builder/check.go +++ /dev/null @@ -1,2037 +0,0 @@ -package builder - -import ( - "bufio" - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "os" - "os/exec" - "path/filepath" - "regexp" - "sort" - "strings" - "sync" - "time" - - "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/artifacts" -) - -// A pull request's merge check, run on the build seat (novox/hq to-be 45 §9, ADR 0237). -// -// **The build machine already has what a check needs**: the repositories, a container runtime, the -// artifact store where the controller keeps the facts snapshot, and a Go toolchain. So a check is one -// more kind of work on the build seat's queue rather than a CI the mesh would have to run beside itself. -// -// **Two layers, each its own status on the pull request** (ADR 0237 as amended, 2026-10-06): -// -// - **the gate** (`mesh/merge-gate`) runs when the change touches a module of the mesh's graph — the -// controller, which holds the graph, says which (Modules) and which directories it adds a module in -// (New). The touched manifests through `module check`; every machine of the facts snapshot composed -// with the change and validated by the node-engine's own validator; then mesh-lab's replays. The -// judge is the controller the mesh runs — or, for a change to the controller, the change's own -// controller. A change to the node-engine is judged as any module is: the validator is mesh-sdk's, at -// the controller's pin. The graph decides whether this runs, never the repository. -// - **the repository's own check** (`mesh/repo-check`): its merge-check.sh, its unit tests and code -// quality, run when present in the toolchain it declares (`# mesh-check-toolchain: go|typescript` -// among its first lines; go when it declares none). A repository that reaches the build seat with -// none is said as a warning: it is the mesh's, and nothing of its own is tested before it merges. -// **A repository in two languages declares the other part too** (`# mesh-check-also: -//