Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d51324df45 | ||
|
|
58512a9c6d | ||
|
|
1fd833bfe8 | ||
|
|
f9014452d3 | ||
|
|
61449249a8 | ||
|
|
61f7e89ad5 | ||
|
|
9713ceb84d | ||
|
|
93fc2f48a1 | ||
|
|
d5d53893a4 | ||
|
|
a0b2d98002 | ||
|
|
bc80683b9e | ||
|
|
96441ea28a | ||
|
|
420ae3d0e7 | ||
|
|
7c0e1d223f | ||
|
|
a10ef8995b | ||
|
|
dfc2f2aa98 | ||
|
|
ab33f015ec | ||
|
|
791c2a3d48 | ||
|
|
d36e0ea278 | ||
|
|
b6ed18197b | ||
|
|
45d091778c | ||
|
|
3a1937914e | ||
|
|
25fdf9920e | ||
|
|
323e1270cc | ||
|
|
e7e23c57cc | ||
|
|
a8387ebad8 | ||
|
|
1963d84a7a | ||
|
|
b71425867f | ||
|
|
c7039d81e1 | ||
|
|
f76da2ce9c | ||
|
|
7f6d418205 | ||
|
|
0aec975d19 | ||
|
|
9da79ef8c3 | ||
|
|
7743bd0d4b | ||
|
|
62268cf43d | ||
|
|
ea3c8ad7f6 | ||
|
|
d984f0a55d | ||
|
|
f00b6aff8b | ||
|
|
eff0d5cbf4 | ||
|
|
1ed7e34155 | ||
|
|
abe356a262 | ||
|
|
41c9628757 | ||
|
|
3e3400f510 | ||
|
|
e022a3fd6f | ||
|
|
684a8783e5 | ||
|
|
11e364bd43 | ||
|
|
1e9b87e03a | ||
|
|
45a09f90e3 | ||
|
|
4538d05732 | ||
|
|
980d396d40 | ||
|
|
b8f77577fe | ||
|
|
28a278c146 | ||
|
|
c34ab0e43a | ||
|
|
771cc24fcb | ||
|
|
b3786cc67e | ||
|
|
b7d03b6897 | ||
|
|
21954ad1bc | ||
|
|
485eb44de4 | ||
|
|
8d562f9b22 | ||
|
|
8f2d22db0c | ||
|
|
cc8a74fce4 | ||
|
|
aa9da94260 | ||
|
|
01f615aa0a | ||
|
|
f501eaca43 | ||
|
|
995f8237ce | ||
|
|
707bfba4ea | ||
|
|
4463991e06 | ||
|
|
4357b3688f | ||
|
|
03927a6f6c | ||
|
|
499ff5c218 | ||
|
|
3d4ad6fefb | ||
|
|
6bf38072ca | ||
|
|
b174d743c0 | ||
|
|
37119ea31f | ||
|
|
af2c6b5e94 | ||
|
|
a929df9b0e | ||
|
|
a3c965b9a0 | ||
|
|
b9ec1919b8 | ||
|
|
d491356f59 | ||
|
|
47e1819101 | ||
|
|
0e25ed7c5a | ||
|
|
79e2923dc8 | ||
|
|
1f3fdf3111 | ||
|
|
9902a3f16c | ||
|
|
11815c0fb3 | ||
|
|
f973cefd7f | ||
|
|
696300b9e2 | ||
|
|
98155e327b | ||
|
|
d6db334840 | ||
|
|
69d016bfdf | ||
|
|
a58b38cee3 | ||
|
|
9d260412b3 | ||
|
|
99610e9569 | ||
|
|
dc06af1f5a | ||
|
|
b62fbeabbf | ||
|
|
373aefd14e | ||
|
|
f66458fe25 | ||
|
|
99da867a98 | ||
|
|
69a67876c2 |
@@ -90,15 +90,6 @@ redis-provisioner-image:
|
|||||||
@echo
|
@echo
|
||||||
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
# The proxy that turns a route grant into traffic reaching a workload.
|
|
||||||
PROXY_IMAGE ?= mesh-route-proxy:$(VERSION)
|
|
||||||
PROXY_DEV_TAG ?= mesh-route-proxy:development
|
|
||||||
|
|
||||||
proxy-image:
|
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f modules/route-proxy/Dockerfile -t $(PROXY_IMAGE) -t $(PROXY_DEV_TAG) .
|
|
||||||
@echo
|
|
||||||
@docker image inspect $(PROXY_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
|
||||||
|
|
||||||
# The whole check. Raises a database, runs the repository's own check against it -- merge-check.sh,
|
# 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
|
# 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
|
# check fails, which is why the teardown is not conditional and the script's exit status is the
|
||||||
@@ -117,7 +108,7 @@ proxy-image:
|
|||||||
# as the script runs them: a bus per test, of the release the mesh runs, makes the suite the same in any
|
# 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.
|
# order, and the timeout bounds a hang to a failure with a stack, never a stalled gate.
|
||||||
check: postgres
|
check: postgres
|
||||||
@sh merge-check.sh ; status=$$? ; docker rm -f $(PG_CONTAINER) >/dev/null 2>&1 || true ; exit $$status
|
@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
|
# 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
|
# that need a bus or the store carry `//go:build integration` and run in `check`, not here; without a database the
|
||||||
@@ -135,7 +126,7 @@ fmt:
|
|||||||
if [ -n "$$unformatted" ] ; then echo "not gofmt'd:" ; echo "$$unformatted" ; exit 1 ; fi
|
if [ -n "$$unformatted" ] ; then echo "not gofmt'd:" ; echo "$$unformatted" ; exit 1 ; fi
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
@docker rm -f $(PG_CONTAINER) >/dev/null 2>&1 || true
|
@docker rm -f -v $(PG_CONTAINER) >/dev/null 2>&1 || true
|
||||||
@docker run -d --name $(PG_CONTAINER) -e POSTGRES_PASSWORD=check \
|
@docker run -d --name $(PG_CONTAINER) -e POSTGRES_PASSWORD=check \
|
||||||
-p 127.0.0.1:$(PG_PORT):5432 $(PG_IMAGE) >/dev/null
|
-p 127.0.0.1:$(PG_PORT):5432 $(PG_IMAGE) >/dev/null
|
||||||
@printf 'waiting for postgres'
|
@printf 'waiting for postgres'
|
||||||
@@ -147,7 +138,7 @@ postgres:
|
|||||||
echo ' — never came up' ; docker logs $(PG_CONTAINER) | tail -20 ; exit 1
|
echo ' — never came up' ; docker logs $(PG_CONTAINER) | tail -20 ; exit 1
|
||||||
|
|
||||||
postgres-stop:
|
postgres-stop:
|
||||||
@docker rm -f $(PG_CONTAINER) >/dev/null 2>&1 || true
|
@docker rm -f -v $(PG_CONTAINER) >/dev/null 2>&1 || true
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf build/
|
rm -rf build/
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"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
|
// **The build agent says whether it is busy, and being asked drains it** (novox/hq ADR 0335 §1, §5). Its
|
||||||
@@ -73,14 +77,77 @@ func drainedTooLong(now time.Time) error {
|
|||||||
"within %s (novox/hq ADR 0335)", began.UTC().Format(time.RFC3339), drainTooLong)
|
"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
|
// claim marks a request in hand, unless the agent is drained: then nothing is in hand, and the request goes
|
||||||
// back to the queue.
|
// back to the queue.
|
||||||
func claim() bool {
|
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.
|
// 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)
|
f, err := os.OpenFile(inHandMark(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Unmarked, an ask would say idle while this build runs: the build is not taken.
|
// Unmarked, an ask would say idle while this build runs: the build is not taken.
|
||||||
fmt.Fprintf(os.Stderr, "cannot mark a build in hand (%s), so it is handed back: %v\n", inHandMark(), err)
|
markFailed(err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
_, err = f.WriteString(time.Now().UTC().Format(time.RFC3339) + "\n")
|
_, err = f.WriteString(time.Now().UTC().Format(time.RFC3339) + "\n")
|
||||||
@@ -89,7 +156,7 @@ func claim() bool {
|
|||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(inHandMark())
|
_ = os.Remove(inHandMark())
|
||||||
fmt.Fprintf(os.Stderr, "cannot mark a build in hand (%s), so it is handed back: %v\n", inHandMark(), err)
|
markFailed(err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if drained() {
|
if drained() {
|
||||||
@@ -97,6 +164,7 @@ func claim() bool {
|
|||||||
fmt.Fprintln(os.Stderr, "asked whether busy, so this agent takes no new build: handed back")
|
fmt.Fprintln(os.Stderr, "asked whether busy, so this agent takes no new build: handed back")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
inHand.Store(true)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +175,7 @@ func holding(what string) {
|
|||||||
|
|
||||||
// released ends the build in hand.
|
// released ends the build in hand.
|
||||||
func released() {
|
func released() {
|
||||||
|
inHand.Store(false)
|
||||||
if err := os.Remove(inHandMark()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
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)
|
fmt.Fprintf(os.Stderr, "cannot remove the mark of the build in hand (%s): %v\n", inHandMark(), err)
|
||||||
}
|
}
|
||||||
@@ -144,3 +213,23 @@ func busy() (string, error) {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("busy: %s, in hand since %s; taking no new build", what, since), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -160,3 +161,79 @@ func TestAnUnknownCommandFails(t *testing.T) {
|
|||||||
t.Fatalf("help failed: %v", err)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,3 +36,17 @@ func TestTheSiblingsAnAskNamesReachTheSpecAndTheOutcome(t *testing.T) {
|
|||||||
t.Fatalf("a request that names none has siblings %+v", plain.Siblings)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func TestKillEndsTheBuildRunningHereAndNoOther(t *testing.T) {
|
|||||||
t.Errorf("kill said %q", said)
|
t.Errorf("kill said %q", said)
|
||||||
}
|
}
|
||||||
// Removed at the kill and again once the build had ended: a container made in between is caught.
|
// Removed at the kill and again once the build had ended: a container made in between is caught.
|
||||||
if len(removed) != 4 || !strings.Contains(removed[0], "label=mesh.build=build-1") || !strings.Contains(removed[2], "label=mesh.build=build-1") {
|
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)
|
t.Errorf("removed %v", removed)
|
||||||
}
|
}
|
||||||
if c := h.current(); c.Running != nil {
|
if c := h.current(); c.Running != nil {
|
||||||
@@ -160,7 +160,7 @@ func TestAHolderStartingRemovesWhatEarlierBuildsLeftHere(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if n := h.removeLeftBehind(); n != 2 || len(removed) != 2 || removed[1] != "docker rm -f c1 c2" {
|
if n := h.removeLeftBehind(); n != 2 || len(removed) != 3 || removed[2] != "docker rm -f -v c1 c2" {
|
||||||
t.Fatalf("removed %d: %v", n, removed)
|
t.Fatalf("removed %d: %v", n, removed)
|
||||||
}
|
}
|
||||||
h.remove = func(context.Context, string, string, ...string) (string, error) {
|
h.remove = func(context.Context, string, string, ...string) (string, error) {
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ is dialled except the broker.
|
|||||||
MESH_NPM_TOKEN the token for it, likewise
|
MESH_NPM_TOKEN the token for it, likewise
|
||||||
MESH_NPM_SCOPE the scope it answers for (default: @novox)
|
MESH_NPM_SCOPE the scope it answers for (default: @novox)
|
||||||
MESH_WORKSPACE where to clone and build (default: a temporary directory)
|
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
|
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:
|
broker to take work from or a registry to publish into:
|
||||||
@@ -137,6 +139,10 @@ func run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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")
|
workspace := os.Getenv("MESH_WORKSPACE")
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
workspace = os.TempDir() + "/mesh-builder"
|
workspace = os.TempDir() + "/mesh-builder"
|
||||||
@@ -184,7 +190,7 @@ func run() error {
|
|||||||
// Paused or drained, it takes nothing new (novox/hq ADR 0219, ADR 0335): a drained agent finishes the build
|
// 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.
|
// in hand and then says idle, and its container is replaced between builds.
|
||||||
machine := link.MachineOverNATSWith(js, on, seat, link.MachineOptions{
|
machine := link.MachineOverNATSWith(js, on, seat, link.MachineOptions{
|
||||||
Paused: func() bool { return h.Paused() || drained() },
|
Paused: func() bool { return h.Paused() || drained() || markFaulty(time.Now()) || noRoom(workspace) },
|
||||||
Taking: func() { taking(seat) },
|
Taking: func() { taking(seat) },
|
||||||
Claim: claim,
|
Claim: claim,
|
||||||
})
|
})
|
||||||
@@ -499,6 +505,10 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
|
|||||||
Modules: c.Modules, New: c.New, Manifests: c.Manifests, Judge: c.Judge, Base: c.Base,
|
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),
|
Toolchain: builder.ToolchainOf(request.Held), Toolchains: builder.ToolchainsOf(request.Held),
|
||||||
Tests: c.Tests, StoreOnly: c.StoreOnly, SiblingsUnknown: c.SiblingsUnknown}
|
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 {
|
for _, s := range c.Siblings {
|
||||||
spec.Siblings = append(spec.Siblings, builder.Sibling{Repository: s.Repository, Number: s.Number, Head: s.Head,
|
spec.Siblings = append(spec.Siblings, builder.Sibling{Repository: s.Repository, Number: s.Number, Head: s.Head,
|
||||||
Base: s.Base, Dir: s.Dir})
|
Base: s.Base, Dir: s.Dir})
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func TestALookComesBackForATierTheBusRefusedWhole(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
bus := false
|
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
|
was := askABuild
|
||||||
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
||||||
if !bus {
|
if !bus {
|
||||||
@@ -95,6 +96,7 @@ func TestALookFailsTheTierTheBusRefusedPastTheBound(t *testing.T) {
|
|||||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
t.Fatal(err)
|
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
|
was := askABuild
|
||||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
||||||
return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain)
|
return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain)
|
||||||
@@ -114,11 +116,6 @@ func TestALookFailsTheTierTheBusRefusedPastTheBound(t *testing.T) {
|
|||||||
t.Fatalf("a bus silent past its bound did not fail the plan in words: %s %q", got.State, got.Note)
|
t.Fatalf("a bus silent past its bound did not fail the plan in words: %s %q", got.State, got.Note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuilt by hand while the bus still reloads: said as waiting, not as a failure and not with an empty reason.
|
|
||||||
joined, said, err := joinAPlan(ctx, open, "a")
|
|
||||||
if err != nil || !joined || !strings.Contains(said, "not asked yet: the bus did not answer") {
|
|
||||||
t.Fatalf("a rebuild left for the bus is said as %v %q %v", joined, said, err)
|
|
||||||
}
|
|
||||||
// And retried by hand: the module is named as waiting, not as asked again.
|
// And retried by hand: the module is named as waiting, not as asked again.
|
||||||
got, _ = inv.PlanByID(ctx, "plan-bus")
|
got, _ = inv.PlanByID(ctx, "plan-bus")
|
||||||
if got.State == inventory.PlanFailed {
|
if got.State == inventory.PlanFailed {
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ import (
|
|||||||
// seconds) is not a failed build. The module is left unasked and the plan building, and the next look asks it again; only a
|
// 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.
|
// 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) {
|
func aTierOfThree() (*inventory.Plan, map[string]inventory.Entry) {
|
||||||
p := &inventory.Plan{ID: "plan-1", State: inventory.PlanBuilding, Tiers: [][]string{{"a", "b", "c"}},
|
p := &inventory.Plan{ID: "plan-1", State: inventory.PlanBuilding, Tiers: [][]string{{"a", "b", "c"}},
|
||||||
Modules: map[string]*inventory.PlanModule{}}
|
Modules: map[string]*inventory.PlanModule{}}
|
||||||
@@ -26,6 +35,7 @@ func aTierOfThree() (*inventory.Plan, map[string]inventory.Entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestABusThatDoesNotAnswerForAMomentDoesNotFailTheTier(t *testing.T) {
|
func TestABusThatDoesNotAnswerForAMomentDoesNotFailTheTier(t *testing.T) {
|
||||||
|
asksOfEveryModule(t)
|
||||||
p, byName := aTierOfThree()
|
p, byName := aTierOfThree()
|
||||||
bus := false
|
bus := false
|
||||||
was := askABuild
|
was := askABuild
|
||||||
@@ -68,6 +78,7 @@ func TestABusThatDoesNotAnswerForAMomentDoesNotFailTheTier(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestABusSilentForTenMinutesFailsTheTierAsBefore(t *testing.T) {
|
func TestABusSilentForTenMinutesFailsTheTierAsBefore(t *testing.T) {
|
||||||
|
asksOfEveryModule(t)
|
||||||
p, byName := aTierOfThree()
|
p, byName := aTierOfThree()
|
||||||
was := askABuild
|
was := askABuild
|
||||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
||||||
@@ -119,6 +130,7 @@ func TestAPlanWithAModuleWaitingForTheBusIsOneTheLookComesBackTo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAModulesOwnFaultStillFailsThePlan(t *testing.T) {
|
func TestAModulesOwnFaultStillFailsThePlan(t *testing.T) {
|
||||||
|
asksOfEveryModule(t)
|
||||||
p, byName := aTierOfThree()
|
p, byName := aTierOfThree()
|
||||||
delete(byName, "a")
|
delete(byName, "a")
|
||||||
askModule(t.Context(), p, "a", byName)
|
askModule(t.Context(), p, "a", byName)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A retry of a plan failed past the bus's bound, whose module the bus still does not answer, names that module as waiting:
|
||||||
|
// read while the plan still says failed, it was named nowhere, and the retry said "asked" with nothing after it (issue
|
||||||
|
// 1403, met again once the hand rebuild no longer resumed the plan first).
|
||||||
|
func TestARetryNamesTheModulesStillWaitingForTheBus(t *testing.T) {
|
||||||
|
since := time.Now().UTC()
|
||||||
|
p := inventory.Plan{ID: "plan-bus", State: inventory.PlanFailed, Tiers: [][]string{{"a"}},
|
||||||
|
Modules: map[string]*inventory.PlanModule{"a": {BusWaitFrom: &since}}}
|
||||||
|
waiting := busWaitingOnRetry(p)
|
||||||
|
_, said := retryWords(p, []string{"a"}, nil, waiting)
|
||||||
|
if !strings.Contains(said, "a not asked yet: the bus did not answer") || strings.Contains(said, "asked ;") {
|
||||||
|
t.Fatalf("a retry left for the bus is said as %q", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -645,6 +645,10 @@ func doesWords(act conditions.Action) string {
|
|||||||
return "the delivery is stopped"
|
return "the delivery is stopped"
|
||||||
case act.Verb == broker.ControllerSeat+".plans" && act.Arguments["retry"] != "":
|
case act.Verb == broker.ControllerSeat+".plans" && act.Arguments["retry"] != "":
|
||||||
return "the failed builds are asked again"
|
return "the failed builds are asked again"
|
||||||
|
case act.Verb == broker.ControllerSeat+".push" && act.Arguments["node"] != "":
|
||||||
|
return "the held builds are sent to " + act.Arguments["node"]
|
||||||
|
case act.Verb == broker.ControllerSeat+".upgrade" && act.Arguments["release-backlog"] != "":
|
||||||
|
return "the builds held after the failed delivery are released"
|
||||||
case strings.HasSuffix(act.Verb, ".restart"):
|
case strings.HasSuffix(act.Verb, ".restart"):
|
||||||
return "its service is restarted on " + act.Machine
|
return "its service is restarted on " + act.Machine
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ func TestAPlanGoesOnWithoutAMachineAwayWhenItsTierIsSent(t *testing.T) {
|
|||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var asks []string
|
var asks []string
|
||||||
var sends [][]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
|
wasAsk := askABuild
|
||||||
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
|
|||||||
@@ -232,8 +232,8 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T
|
|||||||
fmt.Print(nothingReadsIt(repository, m))
|
fmt.Print(nothingReadsIt(repository, m))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// **A module the merge adds is asked for now** (novox/hq issue 1617), not when the batch is cut.
|
// A module the merge adds is registered from the builder's first done key of it, which the builder's write wakes
|
||||||
askNewModulesAtHearing(ctx, inv, m, entries, read, facts.from)
|
// (novox/hq ADR 0501 §6): nothing is asked here.
|
||||||
// **Its delivery group's word, before the plans are held** (ADR 0306 decision 2): a slow answer holds up no walk.
|
// **Its delivery group's word, before the plans are held** (ADR 0306 decision 2): a slow answer holds up no walk.
|
||||||
own := ownPath(touches)
|
own := ownPath(touches)
|
||||||
waits, holding, err := groupWaitHolding(ctx, inv, m, deliverySeatHeld(entries))
|
waits, holding, err := groupWaitHolding(ctx, inv, m, deliverySeatHeld(entries))
|
||||||
@@ -800,6 +800,9 @@ type keptMerge struct {
|
|||||||
// FirstBuild says the merge is a first merge into a repository the mesh builds nothing from, kept to ask the first
|
// FirstBuild says the merge is a first merge into a repository the mesh builds nothing from, kept to ask the first
|
||||||
// build of its modules once mesh-delivery's word on it is kept (novox/hq ADR 0432, issue 1146).
|
// build of its modules once mesh-delivery's word on it is kept (novox/hq ADR 0432, issue 1146).
|
||||||
FirstBuild bool `json:"first_build,omitempty"`
|
FirstBuild bool `json:"first_build,omitempty"`
|
||||||
|
// Held says which first builds of the merge were refused for a word the running build-agent does not read: the merge
|
||||||
|
// waits for a build-agent that reads them, and is never failed (novox/hq ADR 0482 §5, issue 1173).
|
||||||
|
Held []heldFirstBuild `json:"held,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// announcedOf is a kept merge as the forge announced it, and what it moved; empty where the record says none.
|
// announcedOf is a kept merge as the forge announced it, and what it moved; empty where the record says none.
|
||||||
|
|||||||
+110
-296
@@ -28,112 +28,6 @@ import (
|
|||||||
// cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636:
|
// 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.
|
// nothing in it was wrong, and no one edit was the one that should have been a new file.
|
||||||
|
|
||||||
// buildCommand builds a module from its source and records what came out.
|
|
||||||
//
|
|
||||||
// **Run where there is a container runtime**, which is why it is a command rather than something
|
|
||||||
// the control plane does on its own: building needs to run things on a machine, and what the
|
|
||||||
// control plane may send a machine is bounded by the declaration language. This is the shape the
|
|
||||||
// builder module will take when it is given work over the broker; today a person runs it, and the
|
|
||||||
// mesh records the result the same way either way.
|
|
||||||
// buildOn rebuilds every module the mesh holds that stands on the named module's artifacts — the
|
|
||||||
// rebuild a changed base needs, which nothing else asks for: their sources did not move, and
|
|
||||||
// "behind" does not see a base that did (novox/hq 04-ISSUES/131). Bases first among them too.
|
|
||||||
func buildOn(ctx context.Context, base string, wait time.Duration) error {
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
held, err := open.inventory.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
against, err := open.inventory.BuiltAgainst(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var on []inventory.Entry
|
|
||||||
for _, e := range held {
|
|
||||||
if standsOnModule(e, base, against) {
|
|
||||||
on = append(on, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(on) == 0 {
|
|
||||||
fmt.Printf("nothing the mesh holds stands on %s\n", base)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
on = orderByBases(on, against)
|
|
||||||
fmt.Printf("%d module(s) stand on %s:\n", len(on), base)
|
|
||||||
var failed []string
|
|
||||||
for _, e := range on {
|
|
||||||
fmt.Printf("--- %s\n", e.Manifest.Module)
|
|
||||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
|
||||||
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, wait); err != nil {
|
|
||||||
fmt.Printf(" %v\n", err)
|
|
||||||
failed = append(failed, e.Manifest.Module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(failed) > 0 {
|
|
||||||
return fmt.Errorf("%d of %d could not be built: %s", len(failed), len(on), strings.Join(failed, ", "))
|
|
||||||
}
|
|
||||||
fmt.Printf("\n%d module(s) rebuilt on %s. `push --behind` sends them on\n", len(on), base)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCommand(ctx context.Context, args []string) error {
|
|
||||||
set := flag.NewFlagSet("build", flag.ContinueOnError)
|
|
||||||
ref := set.String("ref", "", "the branch, tag or commit to build")
|
|
||||||
// A module is a repository and a path within it (novox/hq ADR 0069). Empty is the repository's
|
|
||||||
// root, which is the ordinary case and why this is a flag rather than a second argument.
|
|
||||||
path := set.String("path", "", "the module's directory inside the repository")
|
|
||||||
wait := set.Duration("wait", 10*time.Minute, "how long to wait for a builder to answer")
|
|
||||||
dryRun := set.Bool("dry-run", false, "build and print the manifest, recording nothing")
|
|
||||||
// Every module whose source has moved, rather than one named repository.
|
|
||||||
//
|
|
||||||
// **The mirror of `push --behind`, and the same argument** (novox/hq ADR 0010): the mesh
|
|
||||||
// already knows which modules are behind their source, so making a person read that list and
|
|
||||||
// retype each repository is asking them to be the loop. Naming a repository and asking which
|
|
||||||
// ones need building are different requests, so they are not combined.
|
|
||||||
behind := set.Bool("behind", false, "every module the mesh holds older than its source has")
|
|
||||||
on := set.String("on", "", "rebuild every module that stands on this module's artifacts — the rebuild a changed base needs")
|
|
||||||
// A repository on the mesh's own forge, named by its path there (novox/hq ADR 0111). Without it
|
|
||||||
// the repository is external, cloned exactly as given — see source.go.
|
|
||||||
self := set.Bool("self", false, "the repository is a path on the forge holding the git seat")
|
|
||||||
positionals, err := parseAround(set, args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if *on != "" {
|
|
||||||
if len(positionals) != 0 || *behind || *self {
|
|
||||||
return errors.New("build --on <module> names a base and nothing else")
|
|
||||||
}
|
|
||||||
return buildOn(ctx, *on, *wait)
|
|
||||||
}
|
|
||||||
|
|
||||||
if *behind {
|
|
||||||
if len(positionals) != 0 || *self {
|
|
||||||
return errors.New("build <repository> or build --behind, not both: one names a " +
|
|
||||||
"repository and the other asks which need building")
|
|
||||||
}
|
|
||||||
return buildBehind(ctx, *wait)
|
|
||||||
}
|
|
||||||
if len(positionals) != 1 {
|
|
||||||
return errors.New("build <repository> [--self] [--path P] [--ref R] [--wait D] [--dry-run] | build --behind | build --on <module>")
|
|
||||||
}
|
|
||||||
source := buildSource{Repository: positionals[0]}
|
|
||||||
if *self {
|
|
||||||
if err := onASeat(source.Repository); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
source.Seat = gitSeat
|
|
||||||
}
|
|
||||||
|
|
||||||
if *dryRun {
|
|
||||||
return buildAndShow(ctx, source, *path, *ref, *wait)
|
|
||||||
}
|
|
||||||
return buildOne(ctx, source, *path, *ref, *wait)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildFrom turns what a builder said into what the mesh keeps.
|
// 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
|
// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder
|
||||||
@@ -355,86 +249,6 @@ func builderCommand(ctx context.Context, args []string) error {
|
|||||||
return issueOnTheNewBus(ctx, inv, m, node, address)
|
return issueOnTheNewBus(ctx, inv, m, node, address)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildBehind builds every module the mesh holds older than its source has.
|
|
||||||
//
|
|
||||||
// **This is the loop novox/hq ADR 0010 replaced a pipeline with, closed.** The mesh already
|
|
||||||
// records where each module came from and what its source last had; until this, a person read
|
|
||||||
// that list and retyped each repository — which is a person being the loop, and the thing a
|
|
||||||
// pipeline was doing before it was taken away.
|
|
||||||
//
|
|
||||||
// Each is built and recorded on its own. **One failing does not stop the others**, for the same
|
|
||||||
// reason one broken module no longer blocks a machine's whole declaration: a mesh where one bad
|
|
||||||
// repository holds back nine good ones is a mesh where nobody dares add the tenth.
|
|
||||||
func buildBehind(ctx context.Context, wait time.Duration) error {
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
inv := open.inventory
|
|
||||||
|
|
||||||
held, err := inv.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var stale []inventory.Entry
|
|
||||||
for _, e := range held {
|
|
||||||
if !e.Source.Current() {
|
|
||||||
stale = append(stale, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(stale) == 0 {
|
|
||||||
// Said rather than doing nothing quietly: "nothing needed building" and "this did not
|
|
||||||
// run" must never look the same.
|
|
||||||
fmt.Println("every module the mesh holds is what its source last had")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("%d module(s) behind their source:\n", len(stale))
|
|
||||||
for _, e := range stale {
|
|
||||||
fmt.Printf(" %s %s < %s\n",
|
|
||||||
e.Manifest.Module, short(e.Source.BuiltFrom), short(e.Source.Head))
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Bases first: a module built before the module it stands on is built against the old one
|
|
||||||
// and reports success (novox/hq 04-ISSUES/131).
|
|
||||||
against, err := inv.BuiltAgainst(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stale = orderByBases(stale, against)
|
|
||||||
|
|
||||||
var failed []string
|
|
||||||
for _, e := range stale {
|
|
||||||
fmt.Printf("--- %s\n", e.Manifest.Module)
|
|
||||||
// Its own recorded ref, not its head commit: a module tracking a branch should be built
|
|
||||||
// from that branch, and pinning to the commit the mesh happened to notice would quietly
|
|
||||||
// turn a tracked branch into a pin.
|
|
||||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
|
||||||
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, wait); err != nil {
|
|
||||||
fmt.Printf(" %v\n", err)
|
|
||||||
failed = append(failed, e.Manifest.Module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(failed) > 0 {
|
|
||||||
return fmt.Errorf("%d of %d could not be built: %s",
|
|
||||||
len(failed), len(stale), strings.Join(failed, ", "))
|
|
||||||
}
|
|
||||||
fmt.Printf("\n%d module(s) built. `push --behind` sends them to the machines running them\n",
|
|
||||||
len(stale))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildOne asks a build machine for one repository and records everything that came back.
|
|
||||||
//
|
|
||||||
// Separated from the command so `--behind` can walk a list without a second path to the same act.
|
|
||||||
func buildOne(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error {
|
|
||||||
_, err := buildOneAsked(ctx, source, path, ref, wait, false, "build")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordAsked keeps a build request once it is asked (novox/hq issue 325), with who asked it, in a store
|
// 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.
|
// opened for the purpose: the asks reach here from processes that hold none.
|
||||||
func recordAsked(ctx context.Context, r inventory.BuildRequest) {
|
func recordAsked(ctx context.Context, r inventory.BuildRequest) {
|
||||||
@@ -511,7 +325,6 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
|
|||||||
Seats: seatBases(ctx),
|
Seats: seatBases(ctx),
|
||||||
DryRun: dryRun,
|
DryRun: dryRun,
|
||||||
}
|
}
|
||||||
request.Reuse = reuseAsked(ctx, dryRun)
|
|
||||||
fmt.Printf("asked for %s", source)
|
fmt.Printf("asked for %s", source)
|
||||||
if source.Seat != "" {
|
if source.Seat != "" {
|
||||||
fmt.Printf(" (%s)", repository)
|
fmt.Printf(" (%s)", repository)
|
||||||
@@ -679,13 +492,6 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
recorded.Ref = followedBranch(was.Ref)
|
recorded.Ref = followedBranch(was.Ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := namesNoInstallation(manifest); err != nil {
|
|
||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
|
|
||||||
result.On, result.Repository, short(result.Commit), err)
|
|
||||||
}
|
|
||||||
// **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk
|
|
||||||
// below is the trunk of whatever repository was built, which may be one an agent made — and a module named
|
|
||||||
// `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal.
|
|
||||||
trunk := result.Trunk
|
trunk := result.Trunk
|
||||||
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" {
|
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" {
|
||||||
trunk = followedBranch(was.Ref)
|
trunk = followedBranch(was.Ref)
|
||||||
@@ -693,55 +499,111 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
if trunk == "" {
|
if trunk == "" {
|
||||||
trunk = "main"
|
trunk = "main"
|
||||||
}
|
}
|
||||||
repoID, err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID, result.Path, trunk)
|
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
|
||||||
|
// source moves nothing are the same whichever part built it (ADR 0501 §1, §3).
|
||||||
|
type takenBuild struct {
|
||||||
|
kept inventory.Build
|
||||||
|
manifest catalogue.Manifest
|
||||||
|
recorded inventory.Source
|
||||||
|
ask registrationAsk
|
||||||
|
path string
|
||||||
|
trunk string
|
||||||
|
// by and what name the build in what is said: who built it, and what was built ("ace", "novox/mesh-catalog").
|
||||||
|
by, what, commit string
|
||||||
|
// published refuses a build that may not become a module's version (off the trunk, in the scratch namespace);
|
||||||
|
// nil for a key, which is a trunk fingerprint's by construction (ADR 0501 §2).
|
||||||
|
published func() error
|
||||||
|
// carried publishes a list of predefined keys the build carried; nil when it carried none.
|
||||||
|
carried func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerJudged judges a recorded build and registers its module, or says why not.
|
||||||
|
func registerJudged(ctx context.Context, inv *inventory.Inventory, j takenBuild) (catalogue.Manifest, error) {
|
||||||
|
manifest, kept := j.manifest, j.kept
|
||||||
|
notRegistered := func(err error) (catalogue.Manifest, error) {
|
||||||
|
return manifest, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", j.by, manifest.Module,
|
||||||
|
short(j.commit), err)
|
||||||
|
}
|
||||||
|
if err := namesNoInstallation(manifest); err != nil {
|
||||||
|
return manifest, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
|
||||||
|
j.by, j.what, short(j.commit), err)
|
||||||
|
}
|
||||||
|
// **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk
|
||||||
|
// below is the trunk of whatever repository was built, which may be one an agent made — and a module named
|
||||||
|
// `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal.
|
||||||
|
repoID, err := mayRegisterFrom(ctx, inv, manifest.Module, j.recorded, j.ask, j.path, j.trunk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
|
return notRegistered(err)
|
||||||
manifest.Module, short(result.Commit), err)
|
|
||||||
}
|
}
|
||||||
// **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's
|
if j.published != nil {
|
||||||
// default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay
|
if err := j.published(); err != nil {
|
||||||
// --register` of one — is for checking, and is never a module's version; nothing could then send it.
|
return notRegistered(err)
|
||||||
// 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 manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
|
|
||||||
manifest.Module, short(result.Commit), 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 manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %s is in the scratch "+
|
|
||||||
"namespace, which only a walk's copy leaves (ADR 0350)", result.On, manifest.Module, short(result.Commit), scratch)
|
|
||||||
}
|
}
|
||||||
// **A build that failed its gate is never registered again** (novox/hq ADR 0236): an outcome heard
|
// **A build that failed its gate is never registered again** (novox/hq ADR 0236): an outcome heard
|
||||||
// twice, or replayed, would otherwise make the build a rollback put back what the module is again,
|
// twice, or replayed, would otherwise make the build a rollback put back what the module is again,
|
||||||
// and the next push would send it.
|
// and the next push would send it.
|
||||||
if failed, err := inv.GateFailed(ctx, kept.ID); err != nil {
|
if failed, err := inv.GateFailed(ctx, kept.ID); err != nil {
|
||||||
return manifest, kept, err
|
return manifest, err
|
||||||
} else if failed {
|
} else if failed {
|
||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), which failed its gate on its first machine and was put "+
|
return manifest, fmt.Errorf("%s built %s (%s), which failed its gate on its first machine and was put "+
|
||||||
"back: it is recorded and not registered again — a newer build is", result.On, manifest.Module,
|
"back: it is recorded and not registered again — a newer build is", j.by, manifest.Module,
|
||||||
short(result.Commit))
|
short(j.commit))
|
||||||
}
|
}
|
||||||
// **The newest trunk build's list of predefined keys is published** (novox/hq ADR 0348 §7): a build of the
|
// Then the module is judged against the list the mesh publishes, as every manifest read again is: one naming a
|
||||||
// catalogue that may be registered keeps its list when its commit is newer than the one kept. Then the module
|
// key that list does not hold, or of another type, is refused here and never enters the catalogue.
|
||||||
// is judged against the list the mesh publishes, as every manifest read again is: one naming a key that list
|
if j.carried != nil {
|
||||||
// does not hold, or of another type, is refused here and never enters the catalogue.
|
if err := j.carried(); err != nil {
|
||||||
if carried, err := carriedList(result); err != nil {
|
return manifest, err
|
||||||
return manifest, kept, err
|
|
||||||
} else 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if manifest.NamesPredefined() {
|
if manifest.NamesPredefined() {
|
||||||
if _, err := catalogue.ParseManifest(kept.Manifest); err != nil {
|
if _, err := catalogue.ParseManifest(kept.Manifest); err != nil {
|
||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: judged against the "+
|
return notRegistered(fmt.Errorf("judged against the list of predefined keys the mesh publishes, %w", err))
|
||||||
"list of predefined keys the mesh publishes, %w", result.On, manifest.Module, short(result.Commit), err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **A build whose source is unchanged is never a move** (novox/hq issue 280): a rebuild made from
|
// **A build whose source is unchanged is never a move** (novox/hq issue 280): a rebuild made from
|
||||||
@@ -751,35 +613,34 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
if kept.SourceFingerprint != "" {
|
if kept.SourceFingerprint != "" {
|
||||||
stands, raw, err := inv.StandingBuild(ctx, manifest.Module, kept.ID)
|
stands, raw, err := inv.StandingBuild(ctx, manifest.Module, kept.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manifest, kept, err
|
return manifest, err
|
||||||
}
|
}
|
||||||
if stands != "" && stands != kept.ID && len(raw) > 0 {
|
if stands != "" && stands != kept.ID && len(raw) > 0 {
|
||||||
if same, err := catalogue.ParseManifest(raw); err == nil && same.Module == manifest.Module {
|
if was, err := catalogue.ParseManifest(raw); err == nil && was.Module == manifest.Module {
|
||||||
fmt.Printf("%s at %s was made from the source %s was: registered with its artifacts, no move\n",
|
fmt.Printf("%s at %s was made from the source %s was: registered with its artifacts, no move\n",
|
||||||
manifest.Module, short(result.Commit), stands)
|
manifest.Module, short(j.commit), stands)
|
||||||
manifest = same
|
manifest = was
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
if err := inv.RegisterModule(ctx, manifest, j.recorded); err != nil {
|
||||||
if errors.Is(err, inventory.ErrSuperseded) {
|
if errors.Is(err, inventory.ErrSuperseded) {
|
||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w",
|
return notRegistered(err)
|
||||||
result.On, manifest.Module, short(result.Commit), err)
|
|
||||||
}
|
}
|
||||||
return manifest, kept, err
|
return manifest, err
|
||||||
}
|
}
|
||||||
// A bus process the new definition gains gets its account now, on each machine assigned the module, so the
|
// A bus process the new definition gains gets its account now, on each machine assigned the module, so the
|
||||||
// next push composes a real account (issue 1226).
|
// next push composes a real account (issue 1226).
|
||||||
issueGainedProcessAccounts(ctx, inv, manifest)
|
issueGainedProcessAccounts(ctx, inv, manifest)
|
||||||
// Which repository it is registered from, by the forge's own id (novox/hq ADR 0266).
|
// Which repository it is registered from, by the forge's own id (novox/hq ADR 0266).
|
||||||
if err := inv.SetSourceIdentity(ctx, manifest.Module, repoID); err != nil {
|
if err := inv.SetSourceIdentity(ctx, manifest.Module, repoID); err != nil {
|
||||||
return manifest, kept, err
|
return manifest, err
|
||||||
}
|
}
|
||||||
// The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather
|
// The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather
|
||||||
// than on a timer of its own: this is the only moment either is true. Never fatal — the build
|
// than on a timer of its own: this is the only moment either is true. Never fatal — the build
|
||||||
// worked and the module is registered.
|
// worked and the module is registered.
|
||||||
collect(ctx, inv)
|
collect(ctx, inv)
|
||||||
return manifest, kept, nil
|
return manifest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// inScratch is the first artifact a build made into the scratch namespace; empty when none is.
|
// inScratch is the first artifact a build made into the scratch namespace; empty when none is.
|
||||||
@@ -820,64 +681,17 @@ func publishable(result link.BuildResult, follows string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAndShow builds and prints the manifest without recording anything.
|
|
||||||
func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error {
|
|
||||||
repository, err := cloneFrom(ctx, source)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
|
|
||||||
ask, err := askOverOn(buildSeatHeld(ctx))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer ask.Close()
|
|
||||||
|
|
||||||
result, err := ask.Submit(ctx, link.BuildRequest{
|
|
||||||
ID: link.NewBuildID(time.Now()),
|
|
||||||
Repository: repository, Path: path, Ref: ref,
|
|
||||||
Held: heldBy(ctx), Seats: seatBases(ctx),
|
|
||||||
DryRun: true,
|
|
||||||
}, wait)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if result.Failed != "" {
|
|
||||||
return fmt.Errorf("%s could not build %s:\n%s", result.On, result.Repository, result.Failed)
|
|
||||||
}
|
|
||||||
manifest, err := catalogue.ParseManifest(result.Manifest)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
|
||||||
result.On, result.Repository, err)
|
|
||||||
}
|
|
||||||
body, err := json.MarshalIndent(manifest, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println(string(body))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// answers is what the three questions came back with, read once.
|
// answers is what the three questions came back with, read once.
|
||||||
type answers struct {
|
type answers struct {
|
||||||
wrong []inventory.Doing
|
// notAnnounced are the owners some module reads the state of that have announced nothing yet (novox/hq ADR 0495,
|
||||||
nodes []inventory.Node
|
// issue 1790): read from `reads` as before until they do; notAnnouncedWhy why that could not be read.
|
||||||
quiet []inventory.Node
|
notAnnounced []string
|
||||||
behind map[string][]string
|
notAnnouncedWhy string
|
||||||
sources map[string]inventory.Source
|
wrong []inventory.Doing
|
||||||
|
nodes []inventory.Node
|
||||||
|
quiet []inventory.Node
|
||||||
|
behind map[string][]string
|
||||||
|
sources map[string]inventory.Source
|
||||||
// waiting is every machine not running what the mesh would send it.
|
// waiting is every machine not running what the mesh would send it.
|
||||||
waiting []inventory.Machine
|
waiting []inventory.Machine
|
||||||
// recordedWaits is, per machine, every move of a recorded module that waits there for a person (novox/hq ADR
|
// recordedWaits is, per machine, every move of a recorded module that waits there for a person (novox/hq ADR
|
||||||
|
|||||||
@@ -373,22 +373,7 @@ type protectionRule struct {
|
|||||||
// branch, else the first glob rule, in the forge's order, that covers it (gitea's first matching rule). Never a
|
// 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.
|
||||||
func judgedRule(rules []protectionRule, branch string) forgeFacts {
|
func judgedRule(rules []protectionRule, branch string) forgeFacts {
|
||||||
var applied *protectionRule
|
switch r := appliedRule(rules, branch); {
|
||||||
for i := range rules {
|
|
||||||
if rules[i].Rule == branch {
|
|
||||||
applied = &rules[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if applied == nil {
|
|
||||||
for i := range rules {
|
|
||||||
if ruleCovers(rules[i].Rule, branch) {
|
|
||||||
applied = &rules[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch r := applied; {
|
|
||||||
case r == nil:
|
case r == nil:
|
||||||
return forgeFacts{Why: fmt.Sprintf("no protection rule covers %s", branch)}
|
return forgeFacts{Why: fmt.Sprintf("no protection rule covers %s", branch)}
|
||||||
case r.Push:
|
case r.Push:
|
||||||
@@ -401,6 +386,22 @@ func judgedRule(rules []protectionRule, branch string) forgeFacts {
|
|||||||
return forgeFacts{Guarded: true}
|
return forgeFacts{Guarded: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appliedRule is the one rule the forge applies to a branch: the rule named for it, else the first glob rule, in the
|
||||||
|
// forge's order, that covers it; nil when none does.
|
||||||
|
func appliedRule(rules []protectionRule, branch string) *protectionRule {
|
||||||
|
for i := range rules {
|
||||||
|
if rules[i].Rule == branch {
|
||||||
|
return &rules[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range rules {
|
||||||
|
if ruleCovers(rules[i].Rule, branch) {
|
||||||
|
return &rules[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ruleCovers says a protection rule's name — a branch, or a glob of them — covers a branch, as the forge reads it.
|
// ruleCovers says a protection rule's name — a branch, or a glob of them — covers a branch, as the forge reads it.
|
||||||
func ruleCovers(rule, branch string) bool {
|
func ruleCovers(rule, branch string) bool {
|
||||||
if rule == branch {
|
if rule == branch {
|
||||||
@@ -469,8 +470,39 @@ func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, o
|
|||||||
// repository and path; or, for the build of a merge, when mesh-delivery announced it on the operator's Approve of
|
// repository and path; or, for the build of a merge, when mesh-delivery announced it on the operator's Approve of
|
||||||
// that head (novox/hq ADR 0420 §7 for a moved module, ADR 0432 for a new one), the forge's checks still holding. path is the module's directory as built; branch the trunk it is registered from.
|
// that head (novox/hq ADR 0420 §7 for a moved module, ADR 0432 for a new one), the forge's checks still holding. path is the module's directory as built; branch the trunk it is registered from.
|
||||||
func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source,
|
func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source,
|
||||||
buildID, path, branch string) (int64, error) {
|
ask registrationAsk, path, branch string) (int64, error) {
|
||||||
return mayRegisterIn(ctx, inv, newSourceForms(ctx, inv), module, built, buildID, path, branch)
|
return mayRegisterIn(ctx, inv, newSourceForms(ctx, inv), module, built, ask, path, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registrationAsk is what a registration's warrant is read from (novox/hq ADR 0501 §3): the build an outcome answers,
|
||||||
|
// whose kept request says whether it was asked at the terminal or for a merge (build-agent's outcome, the builder's own
|
||||||
|
// module until R4g); or, for a take-in from the builder's done key, the merge the indexer's trunk value names and
|
||||||
|
// whether the operator asked the registration at the controller's terminal (ADR 0501 §4). A key asks no build, so it
|
||||||
|
// keeps no request.
|
||||||
|
type registrationAsk struct {
|
||||||
|
// Build is the build whose kept request is read; empty for a take-in from a key.
|
||||||
|
Build string
|
||||||
|
// Merge is the commit of the merge a key's module is registered from (the indexer's trunk value); empty for an
|
||||||
|
// outcome, whose request names it.
|
||||||
|
Merge string
|
||||||
|
// Terminal is a key registration the operator asked at the controller's terminal for this repository and path.
|
||||||
|
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 {
|
||||||
|
case a.Build != "":
|
||||||
|
return a.Build
|
||||||
|
case a.Terminal:
|
||||||
|
return "the registration asked at the terminal"
|
||||||
|
case a.Merge != "":
|
||||||
|
return fmt.Sprintf("the merge %.8s", a.Merge)
|
||||||
|
}
|
||||||
|
return "a key"
|
||||||
}
|
}
|
||||||
|
|
||||||
// catalogueReader is the catalogue's manifests, as the router's records are found in them.
|
// catalogueReader is the catalogue's manifests, as the router's records are found in them.
|
||||||
@@ -490,15 +522,19 @@ type registrations interface {
|
|||||||
|
|
||||||
// mayRegisterIn is mayRegisterFrom on what it reads of the mesh handed in.
|
// mayRegisterIn is mayRegisterFrom on what it reads of the mesh handed in.
|
||||||
func mayRegisterIn(ctx context.Context, inv registrations, forms *sourceForms, module string, built inventory.Source,
|
func mayRegisterIn(ctx context.Context, inv registrations, forms *sourceForms, module string, built inventory.Source,
|
||||||
buildID, path, branch string) (int64, error) {
|
ask registrationAsk, path, branch string) (int64, error) {
|
||||||
|
buildID := ask.String()
|
||||||
was, err := inv.SourceOf(ctx, module)
|
was, err := inv.SourceOf(ctx, module)
|
||||||
isNew := errors.Is(err, inventory.ErrNoSuchModule)
|
isNew := errors.Is(err, inventory.ErrNoSuchModule)
|
||||||
if err != nil && !isNew {
|
if err != nil && !isNew {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
terminal, err := askedHereFor(ctx, inv, forms, buildID, built, path)
|
terminal := ask.Terminal
|
||||||
if err != nil {
|
if ask.Build != "" {
|
||||||
return 0, err
|
var err error
|
||||||
|
if terminal, err = askedHereFor(ctx, inv, forms, ask.Build, built, path); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
refuse := func(why string) (int64, error) {
|
refuse := func(why string) (int64, error) {
|
||||||
return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+
|
return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+
|
||||||
@@ -542,7 +578,7 @@ func mayRegisterIn(ctx context.Context, inv registrations, forms *sourceForms, m
|
|||||||
// The forge's protection and the repository's identity are checked below as for every registration.
|
// The forge's protection and the repository's identity are checked below as for every registration.
|
||||||
movedWhy := ""
|
movedWhy := ""
|
||||||
if why != "" && !terminal {
|
if why != "" && !terminal {
|
||||||
accepted, err := approvedMove(ctx, inv, forms, buildID, built)
|
accepted, err := approvedMove(ctx, inv, forms, ask, built)
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
movedWhy, why = accepted, ""
|
movedWhy, why = accepted, ""
|
||||||
@@ -662,37 +698,6 @@ func sourceWords(repository, seat string) string {
|
|||||||
return buildSource{Repository: repository, Seat: seat}.String()
|
return buildSource{Repository: repository, Seat: seat}.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstBuildWarrant is what the controller checked before it asks the first build of the modules a merge adds to a
|
|
||||||
// repository nothing is built from: the merge was made on the operator's Approve of its head (approvedAnnounced). It
|
|
||||||
// travels in the context of that ask alone, in this process, so no verb carries one.
|
|
||||||
type firstBuildWarrant struct {
|
|
||||||
repository string // owner/name on the git seat
|
|
||||||
paths map[string]bool
|
|
||||||
why string
|
|
||||||
}
|
|
||||||
|
|
||||||
type firstBuildWarrantKey struct{}
|
|
||||||
|
|
||||||
// withFirstBuildWarrant is ctx carrying the warrant for asking the first builds of paths in repository.
|
|
||||||
func withFirstBuildWarrant(ctx context.Context, repository string, paths []string, why string) context.Context {
|
|
||||||
w := &firstBuildWarrant{repository: repository, paths: map[string]bool{}, why: why}
|
|
||||||
for _, p := range paths {
|
|
||||||
w.paths[strings.Trim(p, "/")] = true
|
|
||||||
}
|
|
||||||
return context.WithValue(ctx, firstBuildWarrantKey{}, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstBuildWarrantIn(ctx context.Context) *firstBuildWarrant {
|
|
||||||
w, _ := ctx.Value(firstBuildWarrantKey{}).(*firstBuildWarrant)
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// covers says the warrant is for exactly this ask: the repository on the git seat, and a module path the merge holds.
|
|
||||||
func (w *firstBuildWarrant) covers(source buildSource, path string) bool {
|
|
||||||
return w != nil && source.Seat == gitSeat && strings.EqualFold(strings.Trim(source.Repository, "/"), w.repository) &&
|
|
||||||
w.paths[strings.Trim(path, "/")]
|
|
||||||
}
|
|
||||||
|
|
||||||
// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from
|
// 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
|
// (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.
|
// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked.
|
||||||
@@ -700,11 +705,6 @@ func verbMayAsk(ctx context.Context, source buildSource, url, path string) error
|
|||||||
if startedAtTheTerminal() {
|
if startedAtTheTerminal() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// **Or the first build of a module a merge adds on the operator's Approve** (novox/hq ADR 0432, issue 1146): the
|
|
||||||
// controller's own ask, carrying the warrant it checked, for that repository and the module paths the merge holds.
|
|
||||||
if firstBuildWarrantIn(ctx).covers(source, path) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
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/testbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// builderOnTheBus is the builder's and the indexer's buckets on a test's own bus, which the controller's connection for
|
||||||
|
// the moment (servingBus) reaches: what a take-in from a key reads (R4e, issue 1851).
|
||||||
|
type builderOnTheBus struct {
|
||||||
|
js *broker.JetStream
|
||||||
|
builds, modules, manifests jetstream.KeyValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func aBuilderOnTheBus(t *testing.T) builderOnTheBus {
|
||||||
|
t.Helper()
|
||||||
|
js, err := broker.Dial(testbus.URL(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(js.Close)
|
||||||
|
api, err := jetstream.New(js.Conn())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := t.Context()
|
||||||
|
b := builderOnTheBus{js: js}
|
||||||
|
for _, c := range []struct {
|
||||||
|
into *jetstream.KeyValue
|
||||||
|
bucket string
|
||||||
|
history uint8
|
||||||
|
}{
|
||||||
|
{&b.builds, sdkdelivery.BuilderBuildsBucket, sdkdelivery.BuilderBuildsHistory},
|
||||||
|
{&b.modules, sdkdelivery.ModulesBucket, sdkdelivery.ModulesHistory},
|
||||||
|
{&b.manifests, sdkdelivery.ManifestsBucket, 1},
|
||||||
|
} {
|
||||||
|
kv, err := api.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: c.bucket, History: c.history})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
*c.into = kv
|
||||||
|
}
|
||||||
|
servingBus.Store(js)
|
||||||
|
t.Cleanup(func() { servingBus.Store(nil) })
|
||||||
|
withTrunkList(t, true)
|
||||||
|
// The indexer on this bus is read, not the "not held" a mesh with no bus reads (aMeshOnTheStore).
|
||||||
|
wasIndexed := indexedModule
|
||||||
|
indexedModule = theIndexedModule
|
||||||
|
t.Cleanup(func() { indexedModule = wasIndexed })
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexed writes the indexer's trunk value of module at commit, naming fp, and its manifest: a module of one archive.
|
||||||
|
func (b builderOnTheBus) indexed(t *testing.T, module, version, repository, path, commit, fp string) string {
|
||||||
|
t.Helper()
|
||||||
|
keys, trunk := aKeyFor(module, version, fp, repository, path, commit)
|
||||||
|
raw := keys.manifests[trunk.Manifest]
|
||||||
|
value, err := sdkdelivery.EncodeManifest(sdkdelivery.Manifest{Module: module, JSON: raw})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := b.manifests.Put(context.Background(), trunk.Manifest, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
trunk.FP = "src1:" + fp[len("src2:"):]
|
||||||
|
encoded, err := sdkdelivery.EncodeModule(trunk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := b.modules.Put(context.Background(), module, encoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return trunk.Manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
// built writes the builder's key of module at fp: done, its one archive named with a reference, or in another state.
|
||||||
|
func (b builderOnTheBus) built(t *testing.T, module, version, fp, manifest, state string) {
|
||||||
|
t.Helper()
|
||||||
|
keys, _ := aKeyFor(module, version, fp, "", "", "")
|
||||||
|
v := keys.keys[module].Build
|
||||||
|
v.Manifest = manifest
|
||||||
|
if state != sdkdelivery.BuildDone {
|
||||||
|
v = sdkdelivery.BuilderBuild{State: state, By: "ace"}
|
||||||
|
if state == sdkdelivery.BuildFailed {
|
||||||
|
v.Error = "digests differ: ace sha256:1, g14 sha256:2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value, err := sdkdelivery.EncodeBuilderBuild(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key, err := sdkdelivery.BuilderBuildKey(module, fp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := b.builds.Put(context.Background(), key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,10 +241,9 @@ func mergeAskPrefix(number int, head string) string {
|
|||||||
return fmt.Sprintf("merge-%d-%s-", number, head)
|
return fmt.Sprintf("merge-%d-%s-", number, head)
|
||||||
}
|
}
|
||||||
|
|
||||||
// askersOfMerges are the askers a merge's warrant is read under: the teller, and mesh-delivery for a merge before release 3e
|
// askersOfMerges are the askers a merge's warrant is read under: the teller alone (ADR 0437 §6), since R4e stopped taking
|
||||||
// (ADR 0437 §6). Release 4's controller work stops accepting mesh-delivery (ADR 0450 §8); a merge asked under it earlier is
|
// mesh-delivery's (ADR 0450 §8; issue 1318).
|
||||||
// still found here until then.
|
func askersOfMerges() []string { return []string{sdkdelivery.TellerModule} }
|
||||||
func askersOfMerges() []string { return []string{sdkdelivery.TellerModule, theDelivery} }
|
|
||||||
|
|
||||||
// caughtUpWarrant judges a chosen merge ask of the router as the warrant of a merge: the ask is the operator's, the warrant
|
// caughtUpWarrant judges a chosen merge ask of the router as the warrant of a merge: the ask is the operator's, the warrant
|
||||||
// answers that very ask as it was published, and it is the operator's Approve proved by the channel it came through (ADR 0437
|
// answers that very ask as it was published, and it is the operator's Approve proved by the channel it came through (ADR 0437
|
||||||
@@ -391,9 +390,6 @@ var findMergeWarrant = func(ctx context.Context, inv catalogueReader, owner, rep
|
|||||||
if asker == sdkdelivery.TellerModule && !strings.HasPrefix(id, sdkdelivery.AskKindMerge+"-") {
|
if asker == sdkdelivery.TellerModule && !strings.HasPrefix(id, sdkdelivery.AskKindMerge+"-") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if asker == theDelivery && !named {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ask, w, err := closedRecordOf(ctx, inv, asker, id)
|
ask, w, err := closedRecordOf(ctx, inv, asker, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, "", err
|
return "", false, "", err
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ func TestAForgeThatDoesNotAnswerIsAnErrorNotAnEmptyTrunk(t *testing.T) {
|
|||||||
|
|
||||||
func TestACaughtUpMergeIsAcceptedOnTheSameWarrantAsAnAnnouncedOne(t *testing.T) {
|
func TestACaughtUpMergeIsAcceptedOnTheSameWarrantAsAnAnnouncedOne(t *testing.T) {
|
||||||
ok := approvedMerge(t)
|
ok := approvedMerge(t)
|
||||||
for _, asker := range []string{sdkdelivery.TellerModule, theDelivery} {
|
if _, err := caughtUpWarrant(sdkdelivery.TellerModule, &ok.ask, &ok.warrant); err != nil {
|
||||||
w := ok.warrant
|
t.Errorf("the operator's Approve under the teller was refused: %v", err)
|
||||||
w.Asker = asker
|
}
|
||||||
if _, err := caughtUpWarrant(asker, &ok.ask, &w); err != nil {
|
// 1318 (ADR 0450 §8): mesh-delivery's warrant of before the gate is no longer taken.
|
||||||
t.Errorf("the operator's Approve under %s was refused: %v", asker, err)
|
if _, err := caughtUpWarrant(theDelivery, &ok.ask, &ok.warrant); err == nil {
|
||||||
}
|
t.Error("a warrant under mesh-delivery was accepted")
|
||||||
}
|
}
|
||||||
if _, err := caughtUpWarrant("keycloak", &ok.ask, &ok.warrant); err == nil {
|
if _, err := caughtUpWarrant("keycloak", &ok.ask, &ok.warrant); err == nil {
|
||||||
t.Error("a warrant of an asker the controller takes none from was accepted")
|
t.Error("a warrant of an asker the controller takes none from was accepted")
|
||||||
@@ -229,34 +229,6 @@ func TestAGroupsWarrantIsFoundByTheAsksWordsForThePullRequestAndItsHead(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keycloak's merge was asked as mesh-delivery.merge-378-b9104454f9a5-1, before release 3e: the id names the pull request and
|
|
||||||
// the first twelve digits of its head, and the warrant is found by them under mesh-delivery.
|
|
||||||
func TestAWarrantAskedUnderMeshDeliveryIsFoundByThePullRequestAndItsHead(t *testing.T) {
|
|
||||||
ok := approvedMerge(t)
|
|
||||||
head := "b9104454f9a5e0c4d3b2a1f0e9d8c7b6a5948372"
|
|
||||||
askID := "merge-378-b9104454f9a5-1"
|
|
||||||
ask, w := ok.ask, ok.warrant
|
|
||||||
ask.ID, w.Ask, w.Asker = askID, askID, theDelivery
|
|
||||||
w.AskDigest = ask.Digest()
|
|
||||||
withRouter(t, map[string]map[string]aRouterRecord{theDelivery: {
|
|
||||||
"merge-377-aaaaaaaaaaaa-1": {},
|
|
||||||
askID: {ask, w},
|
|
||||||
}})
|
|
||||||
why, found, reason, err := findMergeWarrant(t.Context(), nil, "novox", "mesh-catalog", 378, head)
|
|
||||||
if err != nil || !found || !strings.Contains(why, askID) {
|
|
||||||
t.Fatalf("the warrant was not found: %q %v %q %v", why, found, reason, err)
|
|
||||||
}
|
|
||||||
// Another head names another ask, and none is found.
|
|
||||||
if _, found, _, _ := findMergeWarrant(t.Context(), nil, "novox", "mesh-catalog", 378, "ffffffffffff0000"); found {
|
|
||||||
t.Fatal("another head found a warrant")
|
|
||||||
}
|
|
||||||
// A router that cannot be read is an error, never "no warrant".
|
|
||||||
routerAskIDs = func(context.Context, catalogueReader, string) ([]string, error) { return nil, errors.New("no answer") }
|
|
||||||
if _, found, _, err := findMergeWarrant(t.Context(), nil, "novox", "mesh-catalog", 378, head); found || err == nil {
|
|
||||||
t.Fatalf("an unreadable router read as no warrant: %v %v", found, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A trunk shorter than a page, or with none, is read without a fault: the pages end when the forge's do.
|
// A trunk shorter than a page, or with none, is read without a fault: the pages end when the forge's do.
|
||||||
func TestAShortTrunkIsReadWholeAndAnEmptyOneIsNoMerges(t *testing.T) {
|
func TestAShortTrunkIsReadWholeAndAnEmptyOneIsNoMerges(t *testing.T) {
|
||||||
was := trunkCommitsPage
|
was := trunkCommitsPage
|
||||||
|
|||||||
@@ -51,55 +51,13 @@ func attachTheChain(ctx context.Context, inv *inventory.Inventory, server *link.
|
|||||||
fmt.Printf("chain: the builds are not laid out under their modules yet, and none is read until they are: %v\n", err)
|
fmt.Printf("chain: the builds are not laid out under their modules yet, and none is read until they are: %v\n", err)
|
||||||
go layBuildsOutSoon(ctx, inv)
|
go layBuildsOutSoon(ctx, inv)
|
||||||
}
|
}
|
||||||
// And the requests the move of delivery 1 found never handed over, asked again once (askAgain), once it is serving.
|
// The requests the move of delivery 1 found never handed over are not asked again: the controller asks no build
|
||||||
go askAgainSoon(ctx, inv)
|
// (novox/hq ADR 0501; issue 1851, A10), and the builder builds every fingerprint the indexer writes.
|
||||||
// And what delivery 1 recorded of what each machine was sent, copied onto the keys of the assignments (CopySentBuilds).
|
// And what delivery 1 recorded of what each machine was sent, copied onto the keys of the assignments (CopySentBuilds).
|
||||||
go copySentBuildsSoon(ctx, inv)
|
go copySentBuildsSoon(ctx, inv)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// askAgain asks once more for each build request the move found never handed over (novox/hq issue 981 note 7 Q5): the
|
|
||||||
// requests a merge could not ask for, which a person's hand `build` would otherwise have to repeat. **At most once**: a
|
|
||||||
// request is claimed before it is asked and settled after, so a crash between loses the ask, which is where it stood.
|
|
||||||
func askAgain(ctx context.Context, inv *inventory.Inventory) {
|
|
||||||
pending, err := inv.RequestsToAskAgain(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("chain: the requests never handed over cannot be read: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, r := range pending {
|
|
||||||
if claimed, err := inv.ClaimToAskAgain(ctx, r.ID); err != nil || !claimed {
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("chain: %s cannot be claimed to be asked again: %v\n", r.ID, err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, err := askABuild(ctx, buildSource{Repository: r.Repository, Seat: r.Seat}, r.Path, r.Ref)
|
|
||||||
if err != nil {
|
|
||||||
if serr := inv.AskedAgainAs(ctx, r.ID, "not asked again: "+err.Error()); serr != nil {
|
|
||||||
fmt.Printf("chain: %s was not asked again, and that could not be kept: %v\n", r.ID, serr)
|
|
||||||
}
|
|
||||||
fmt.Printf("chain: %s was never handed over and cannot be asked again: %v\n", r.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
recordBuildRequest(ctx, inv, inventory.BuildRequest{ID: id, Repository: r.Repository, Seat: r.Seat, Path: r.Path,
|
|
||||||
Ref: r.Ref, Commit: r.Commit, For: r.For})
|
|
||||||
if err := inv.AskedAgainAs(ctx, r.ID, id); err != nil {
|
|
||||||
fmt.Printf("chain: %s was asked again as %s, and that could not be kept: %v\n", r.ID, id, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("chain: %s, never handed over, is asked again as %s\n", r.ID, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// askAgainSoon waits for the controller to be serving, which an ask needs, and then asks again.
|
|
||||||
func askAgainSoon(ctx context.Context, inv *inventory.Inventory) {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
case <-time.After(time.Minute):
|
|
||||||
askAgain(ctx, inv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// kindSentBuildsNotCopied is the condition of the one-time copy of node.sent_builds onto the assignments' keys that has not
|
// kindSentBuildsNotCopied is the condition of the one-time copy of node.sent_builds onto the assignments' keys that has not
|
||||||
// succeeded: until it has, a module an assignment pulled in may be missing from what a machine is read to run.
|
// succeeded: until it has, a module an assignment pulled in may be missing from what a machine is read to run.
|
||||||
const kindSentBuildsNotCopied = "sent-builds-not-copied"
|
const kindSentBuildsNotCopied = "sent-builds-not-copied"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/builder"
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
@@ -53,7 +52,7 @@ func forgetChecksOfWalk(ctx context.Context, inv *inventory.Inventory, p invento
|
|||||||
if p.WalkSaid != inventory.WalkEnded || p.Delivery == nil {
|
if p.WalkSaid != inventory.WalkEnded || p.Delivery == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, pull := range reuseAskOf(&p).Pulls {
|
for _, pull := range pullsOfWalk(&p) {
|
||||||
said, err := forgetChecks(ctx, inv, builder.PullRef{Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number})
|
said, err := forgetChecks(ctx, inv, builder.PullRef{Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "%s: its check builds stay until the week's sweep: %v\n", p.ID, err)
|
fmt.Fprintf(os.Stderr, "%s: its check builds stay until the week's sweep: %v\n", p.ID, err)
|
||||||
@@ -62,26 +61,3 @@ func forgetChecksOfWalk(ctx context.Context, inv *inventory.Inventory, p invento
|
|||||||
fmt.Printf("%s: %s\n", p.ID, said)
|
fmt.Printf("%s: %s\n", p.ID, said)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// forgetChecksCommand is `forget-checks <owner> <repo> <number>`: the verb the delivery's owner asks when the forge
|
|
||||||
// says a pull request closed unmerged (novox/hq ADR 0350 §5).
|
|
||||||
func forgetChecksCommand(ctx context.Context, args []string) error {
|
|
||||||
if len(args) != 3 {
|
|
||||||
return errors.New("forget-checks <owner> <repo> <number>")
|
|
||||||
}
|
|
||||||
number, err := strconv.Atoi(args[2])
|
|
||||||
if err != nil || number <= 0 {
|
|
||||||
return fmt.Errorf("%q is not a pull request's number", args[2])
|
|
||||||
}
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
said, err := forgetChecks(ctx, open.inventory, builder.PullRef{Owner: args[0], Repo: args[1], Number: number})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println(said)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,23 +14,12 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOnlyAWalksAskTakesAChecksBuildAndADryRunNever(t *testing.T) {
|
func TestAWalkNamesThePullRequestsOfItsMergesOnce(t *testing.T) {
|
||||||
p := &inventory.Plan{Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
|
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: "http://forge.invalid/novox/web.git", Number: 3},
|
||||||
{Repository: "novox/shop", Number: 7}, {Repository: "novox/shop"}}}}
|
{Repository: "novox/shop", Number: 7}, {Repository: "novox/shop"}}}}
|
||||||
asked := reuseAskOf(p)
|
if want := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}, {Owner: "novox", Repo: "web", Number: 3}}; !reflect.DeepEqual(pullsOfWalk(p), want) {
|
||||||
if want := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}, {Owner: "novox", Repo: "web", Number: 3}}; !reflect.DeepEqual(asked.Pulls, want) {
|
t.Fatalf("the walk names %+v", pullsOfWalk(p))
|
||||||
t.Fatalf("the walk names %+v", asked.Pulls)
|
|
||||||
}
|
|
||||||
walk := withReuse(t.Context(), asked)
|
|
||||||
if reuseAsked(walk, false) != asked {
|
|
||||||
t.Fatal("a walk's ask does not carry its leave")
|
|
||||||
}
|
|
||||||
if reuseAsked(walk, true) != nil {
|
|
||||||
t.Fatal("a dry run carries a leave to take a check's build")
|
|
||||||
}
|
|
||||||
if reuseAsked(t.Context(), false) != nil {
|
|
||||||
t.Fatal("an ask that is not a walk's carries a leave")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,44 +104,6 @@ func TestAWalksBuildSaysWhetherItReusedOrBuilt(t *testing.T) {
|
|||||||
// build as it judges one the walk built. A scratch reference is refused. Needs the store (MESH_TEST_POSTGRES), and
|
// 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.
|
// is named in testdata/store-tests.
|
||||||
|
|
||||||
// A walk's leave names the builds its own pull requests' verdicts made at the head each merge merged — never an
|
|
||||||
// earlier head's — read once per walk, and none when they cannot be read: the builder takes nothing a verdict does
|
|
||||||
// not name (novox/hq ADR 0350 §2, issue 703).
|
|
||||||
func TestAWalksLeaveNamesTheBuildsItsPullRequestsVerdictsMade(t *testing.T) {
|
|
||||||
was := checkedBuildsOf
|
|
||||||
forgetWalkVerdicts()
|
|
||||||
t.Cleanup(func() { checkedBuildsOf = was; forgetWalkVerdicts() })
|
|
||||||
var asked [][]link.PullRef
|
|
||||||
pull := link.PullRef{Owner: "novox", Repo: "shop", Number: 7}
|
|
||||||
atHead := link.CheckedBuild{Pull: pull, Check: "build-2", Head: "bbbbbbbbbbbb", Built: link.CheckBuilt{Module: "app", Key: "r1:2"}}
|
|
||||||
earlier := link.CheckedBuild{Pull: pull, Check: "build-1", Head: "aaaaaaaaaaaa", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}
|
|
||||||
checkedBuildsOf = func(_ context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
|
|
||||||
asked = append(asked, pulls)
|
|
||||||
return []link.CheckedBuild{atHead, earlier}, nil
|
|
||||||
}
|
|
||||||
pulls := []link.PullRef{pull}
|
|
||||||
heads := map[link.PullRef]string{pull: "bbbbbbbbbbbb0123456789"}
|
|
||||||
for range 3 {
|
|
||||||
if got := reuseWithVerdicts(t.Context(), "plan-1", &link.ReuseAsk{Pulls: pulls}, heads); !reflect.DeepEqual(got.Builds,
|
|
||||||
[]link.CheckedBuild{atHead}) {
|
|
||||||
t.Fatalf("the leave names %+v, not only the merged head's build", got.Builds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(asked, [][]link.PullRef{pulls}) {
|
|
||||||
t.Fatalf("the verdicts were read %d times for one walk: %v", len(asked), asked)
|
|
||||||
}
|
|
||||||
// A merge whose head was not said takes nothing.
|
|
||||||
if got := reuseWithVerdicts(t.Context(), "plan-1", &link.ReuseAsk{Pulls: pulls}, nil); got.Builds != nil {
|
|
||||||
t.Fatalf("a merge of no said head names %+v", got.Builds)
|
|
||||||
}
|
|
||||||
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
|
|
||||||
return nil, errors.New("no bus")
|
|
||||||
}
|
|
||||||
if got := reuseWithVerdicts(t.Context(), "plan-2", &link.ReuseAsk{Pulls: pulls}, heads); got.Builds != nil {
|
|
||||||
t.Fatalf("verdicts that could not be read named %+v", got.Builds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A check is handed only the builds earlier checks of its own pull request at its own head made: another pull
|
// 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.
|
// 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) {
|
func TestACheckIsHandedOnlyItsOwnHeadsBuilds(t *testing.T) {
|
||||||
@@ -170,24 +132,3 @@ func TestACheckIsHandedOnlyItsOwnHeadsBuilds(t *testing.T) {
|
|||||||
t.Fatalf("verdicts that could not be read named %+v", got)
|
t.Fatalf("verdicts that could not be read named %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func forgetWalkVerdicts() {
|
|
||||||
walkVerdicts.Lock()
|
|
||||||
defer walkVerdicts.Unlock()
|
|
||||||
walkVerdicts.read = map[string]verdictsRead{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A walk's merged heads are the forge's word for each merge; a merge announced without its head, or a pull request
|
|
||||||
// merged at two heads in one walk, has none, and its walk takes no check's build (novox/hq issue 703).
|
|
||||||
func TestAWalksMergedHeadsAreTheForgesWord(t *testing.T) {
|
|
||||||
merge := func(number int, head string) inventory.PlanMerge {
|
|
||||||
return inventory.PlanMerge{Repository: "novox/shop", Number: number, Head: head}
|
|
||||||
}
|
|
||||||
p := &inventory.Plan{Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
|
|
||||||
merge(7, "aaaaaaaaaaaa1"), merge(8, ""), merge(9, "bbbbbbbbbbbb"), merge(9, "cccccccccccc")}}}
|
|
||||||
heads := mergedHeadsOf(p)
|
|
||||||
shop := func(n int) link.PullRef { return link.PullRef{Owner: "novox", Repo: "shop", Number: n} }
|
|
||||||
if heads[shop(7)] != "aaaaaaaaaaaa1" || heads[shop(8)] != "" || heads[shop(9)] != "" {
|
|
||||||
t.Fatalf("merged heads %v", heads)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **A delivery group's member is checked beside the other members' heads** (novox/hq ADR 0382, issue 867).
|
|
||||||
//
|
|
||||||
// A mesh-host change and a mesh-controller change that need each other could not both pass their own merge checks:
|
|
||||||
// each was read beside the other's repository at the commit the mesh runs, which holds neither change. So the
|
|
||||||
// controller asks mesh-delivery's `groups` once for every pull request's check it asks (§1), and names, in the ask,
|
|
||||||
// the group's other open pull requests into a base of the same name, in a repository the build seat reads beside the
|
|
||||||
// check — mesh-lab never — each with its whole head. The build seat reads those repositories at the sibling's merge (§2).
|
|
||||||
// A pull request for which mesh-delivery answered and named no sibling is asked exactly as before (§7).
|
|
||||||
|
|
||||||
// wholeCommit is a commit as the forge says a head: forty hexadecimal characters.
|
|
||||||
var wholeCommit = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
|
||||||
|
|
||||||
// besideRepositories are the repositories (lower case, owner/repo) a check reads beside its own, by the directory each
|
|
||||||
// is found under — the core's, as besideOf clones them, and mesh-sdk beside the controller; never mesh-lab, which is
|
|
||||||
// always read at its trunk.
|
|
||||||
func besideRepositories(entries []inventory.Entry) map[string]string {
|
|
||||||
read := map[string]string{}
|
|
||||||
for _, e := range entries {
|
|
||||||
dir, core := coreModules[e.Manifest.Module]
|
|
||||||
if !core || e.Provided || e.Source.Repository == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
read[nameOf(e.Source.Repository)] = dir
|
|
||||||
if dir == "mesh-controller" && e.Source.Seat != "" {
|
|
||||||
read[nameOf(siblingOf(e.Source.Repository, "mesh-sdk"))] = "mesh-sdk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(read, "")
|
|
||||||
return read
|
|
||||||
}
|
|
||||||
|
|
||||||
// nameOf is a recorded repository as owner/repo in lower case, empty when it names no owner.
|
|
||||||
func nameOf(repository string) string {
|
|
||||||
owner, repo := sourceOwner(repository), sourceName(repository)
|
|
||||||
if owner == "" || repo == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.ToLower(owner + "/" + repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// siblingsFor is the pull request's siblings, as mesh-delivery's groups say them (ADR 0382 §1): the other members of
|
|
||||||
// the group its head branch names that are open, unmerged and into a base of its own base's name, in a repository the
|
|
||||||
// check reads beside it (beside: the directories the ask already names). The second answer is why they could not be
|
|
||||||
// learned, empty when they could: the group's members carry no head or base — a mesh-delivery from before the rule.
|
|
||||||
func siblingsFor(p link.PullUpdated, entries []inventory.Entry, beside map[string]link.CheckedOut,
|
|
||||||
lines []groupsLine) ([]link.CheckSibling, string) {
|
|
||||||
own := strings.ToLower(p.Owner + "/" + p.Repo)
|
|
||||||
var group *groupsLine
|
|
||||||
if p.Head == "" {
|
|
||||||
// A recheck (mesh-delivery's `recheck` through `delivery check`) carries no head branch name: the group is the one
|
|
||||||
// whose members hold this very pull request, by repository and number.
|
|
||||||
learnable := false
|
|
||||||
for i := range lines {
|
|
||||||
for _, m := range lines[i].Details {
|
|
||||||
learnable = true
|
|
||||||
if p.Number != 0 && m.Number == p.Number && strings.ToLower(m.Repository) == own {
|
|
||||||
group = &lines[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if group == nil {
|
|
||||||
for _, l := range lines {
|
|
||||||
if len(l.Details) == 0 && len(l.Members) > 0 && !learnable {
|
|
||||||
return nil, "mesh-delivery named the groups' members without their pull requests (a mesh-delivery from before novox/hq ADR 0382): " +
|
|
||||||
"the group of a pull request announced without its head branch cannot be found"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, "" // a lone pull request
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for i := range lines {
|
|
||||||
if lines[i].ID == p.Head {
|
|
||||||
group = &lines[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if group == nil {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(group.Details) == 0 && len(group.Members) > 0 {
|
|
||||||
return nil, "mesh-delivery named the group's members without their heads and bases (a mesh-delivery from before novox/hq ADR 0382)"
|
|
||||||
}
|
|
||||||
read := besideRepositories(entries)
|
|
||||||
var siblings []link.CheckSibling
|
|
||||||
for _, m := range group.Details {
|
|
||||||
repository := strings.ToLower(m.Repository)
|
|
||||||
if repository == own {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !m.Open {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m.Head == "" || m.Base == "" {
|
|
||||||
return nil, "mesh-delivery named " + m.ID + " without its head or base (a mesh-delivery from before novox/hq ADR 0382)"
|
|
||||||
}
|
|
||||||
dir, readBeside := read[repository]
|
|
||||||
if m.Base != p.Base || !readBeside || dir == "mesh-lab" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, there := beside[dir]; !there {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
head := strings.ToLower(m.Head)
|
|
||||||
if !wholeCommit.MatchString(head) {
|
|
||||||
return nil, fmt.Sprintf("mesh-delivery named %s with a head that is not a whole commit (%q)", m.ID, m.Head)
|
|
||||||
}
|
|
||||||
siblings = append(siblings, link.CheckSibling{Repository: m.Repository, Number: m.Number, Head: head, Base: m.Base, Dir: dir})
|
|
||||||
}
|
|
||||||
sort.Slice(siblings, func(i, j int) bool { return siblings[i].Dir < siblings[j].Dir })
|
|
||||||
return siblings, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// withSiblings asks mesh-delivery's `groups` once and says in the request the pull request's siblings, or why they
|
|
||||||
// could not be learned (ADR 0382 §1): a check never waits on mesh-delivery past groupAskWithin and never fails for it,
|
|
||||||
// so a fix to mesh-delivery is still checked without it.
|
|
||||||
func withSiblings(ctx context.Context, request *link.BuildRequest, p link.PullUpdated, entries []inventory.Entry) {
|
|
||||||
lines, err := groupsAsked(ctx, deliverySeatHeld(entries))
|
|
||||||
siblingsFrom(request, p, entries, lines, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// siblingsFrom is withSiblings from mesh-delivery's answer already had.
|
|
||||||
func siblingsFrom(request *link.BuildRequest, p link.PullUpdated, entries []inventory.Entry, lines []groupsLine, err error) {
|
|
||||||
if request.Check == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
request.Check.SiblingsUnknown = fmt.Sprintf("mesh-delivery did not say the group's pull requests (%v): "+
|
|
||||||
"the check reads no siblings", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request.Check.Siblings, request.Check.SiblingsUnknown = siblingsFor(p, entries, request.Check.Beside, lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// composedMember says the pull request is a member of a delivery group whose gate is the composed check's (novox/hq
|
|
||||||
// ADR 0403 clauses 1 and 7): the group has two members or more and mesh-delivery says so on its line. The group is
|
|
||||||
// found as siblingsFor finds it, by its head branch name, or, for a recheck that carries none, by the pull request's
|
|
||||||
// repository and number — not by siblings, which name only what the check reads beside it.
|
|
||||||
func composedMember(p link.PullUpdated, lines []groupsLine) bool {
|
|
||||||
own := strings.ToLower(p.Owner + "/" + p.Repo)
|
|
||||||
for _, l := range lines {
|
|
||||||
found := p.Head != "" && l.ID == p.Head
|
|
||||||
for _, m := range l.Details {
|
|
||||||
if p.Head == "" && p.Number != 0 && m.Number == p.Number && strings.ToLower(m.Repository) == own {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
return l.ComposedGate && max(len(l.Details), len(l.Members)) >= 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **A delivery group's member is checked beside the other members' heads** (novox/hq ADR 0382 §1, §7, issue 867): the
|
|
||||||
// controller asks mesh-delivery's `groups` once for every check it asks, and names in the ask the group's other open
|
|
||||||
// pull requests into a base of the same name, in a repository the build seat reads beside it — mesh-lab never.
|
|
||||||
|
|
||||||
const (
|
|
||||||
headHost = "1111111111111111111111111111111111111111"
|
|
||||||
headController = "2222222222222222222222222222222222222222"
|
|
||||||
headSDK = "3333333333333333333333333333333333333333"
|
|
||||||
headLab = "4444444444444444444444444444444444444444"
|
|
||||||
headHq = "5555555555555555555555555555555555555555"
|
|
||||||
)
|
|
||||||
|
|
||||||
func theCoreOnTheGitSeat() []inventory.Entry {
|
|
||||||
onSeat := func(module, repository string) inventory.Entry {
|
|
||||||
e := fromRepo(module, repository, "")
|
|
||||||
e.Source.Seat = "git"
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
nats := onSeat("nats", "novox/mesh-catalog")
|
|
||||||
nats.Source.Path = "modules/nats"
|
|
||||||
held := catalogue.Manifest{Module: "mesh-delivery", Claims: []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}}
|
|
||||||
return []inventory.Entry{onSeat("mesh-controller", "novox/mesh-controller"), onSeat("mesh-host", "novox/mesh-host"),
|
|
||||||
onSeat("node-tools", "novox/mesh-tools"), nats, {Manifest: held, On: []string{"anchor"}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func besideTheCore() map[string]link.CheckedOut {
|
|
||||||
return map[string]link.CheckedOut{"mesh-controller": {Repository: "c"}, "mesh-host": {Repository: "h"},
|
|
||||||
"mesh-tools": {Repository: "t"}, "mesh-catalog": {Repository: "k"}, "mesh-sdk": {Repository: "s"}, "mesh-lab": {Repository: "l"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func member(repository string, number int, head, base string, open bool) memberLine {
|
|
||||||
return memberLine{ID: repository + "@" + head[:12], Repository: repository, State: "checked", Number: number, Head: head,
|
|
||||||
Base: base, Open: open}
|
|
||||||
}
|
|
||||||
|
|
||||||
func group(id string, members ...memberLine) groupsLine {
|
|
||||||
line := groupsLine{ID: id, Details: members}
|
|
||||||
for _, m := range members {
|
|
||||||
line.Members = append(line.Members, m.ID+" ("+m.State+")")
|
|
||||||
}
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestASiblingIsTheGroupsOtherOpenPullRequestIntoABaseOfTheSameName(t *testing.T) {
|
|
||||||
entries := theCoreOnTheGitSeat()
|
|
||||||
pull := link.PullUpdated{Owner: "novox", Repo: "mesh-host", Number: 114, Base: "main", Head: "feat/x", Commit: headHost}
|
|
||||||
for _, c := range []struct {
|
|
||||||
what string
|
|
||||||
lines []groupsLine
|
|
||||||
want []string
|
|
||||||
unknown string
|
|
||||||
}{
|
|
||||||
{"the other member, open, same base", []groupsLine{group("feat/x",
|
|
||||||
member("novox/mesh-host", 114, headHost, "main", true), member("novox/mesh-controller", 346, headController, "main", true))},
|
|
||||||
[]string{"mesh-controller=novox/mesh-controller#346@" + headController + " onto main"}, ""},
|
|
||||||
{"a member into a base of another name is not one", []groupsLine{group("feat/x",
|
|
||||||
member("novox/mesh-host", 114, headHost, "main", true), member("novox/mesh-controller", 346, headController, "release", true))},
|
|
||||||
nil, ""},
|
|
||||||
{"a member not open (merged or closed) is not one", []groupsLine{group("feat/x",
|
|
||||||
member("novox/mesh-host", 114, headHost, "main", true), member("novox/mesh-controller", 346, headController, "main", false))},
|
|
||||||
nil, ""},
|
|
||||||
{"mesh-lab is never one", []groupsLine{group("feat/x",
|
|
||||||
member("novox/mesh-host", 114, headHost, "main", true), member("novox/mesh-lab", 5, headLab, "main", true))},
|
|
||||||
nil, ""},
|
|
||||||
{"a repository the check reads nothing of is not one", []groupsLine{group("feat/x",
|
|
||||||
member("novox/mesh-host", 114, headHost, "main", true), member("novox/hq", 9, headHq, "main", true))},
|
|
||||||
nil, ""},
|
|
||||||
{"mesh-sdk, read beside the controller, is one", []groupsLine{group("feat/x",
|
|
||||||
member("novox/mesh-host", 114, headHost, "main", true), member("novox/mesh-sdk", 7, headSDK, "main", true))},
|
|
||||||
[]string{"mesh-sdk=novox/mesh-sdk#7@" + headSDK + " onto main"}, ""},
|
|
||||||
{"another group is none of its business", []groupsLine{group("feat/y",
|
|
||||||
member("novox/mesh-host", 114, headHost, "main", true), member("novox/mesh-controller", 346, headController, "main", true))},
|
|
||||||
nil, ""},
|
|
||||||
{"a group of its own alone has none", []groupsLine{group("feat/x", member("novox/mesh-host", 114, headHost, "main", true))},
|
|
||||||
nil, ""},
|
|
||||||
{"an older mesh-delivery names no heads", []groupsLine{{ID: "feat/x",
|
|
||||||
Members: []string{"novox/mesh-host@111111111111 (checked)", "novox/mesh-controller@222222222222 (checked)"}}},
|
|
||||||
nil, "without their heads and bases"},
|
|
||||||
{"a member without its base", []groupsLine{group("feat/x",
|
|
||||||
member("novox/mesh-host", 114, headHost, "main", true), member("novox/mesh-controller", 346, headController, "", true))},
|
|
||||||
nil, "without its head or base"},
|
|
||||||
{"a head that is no commit", []groupsLine{group("feat/x",
|
|
||||||
member("novox/mesh-host", 114, headHost, "main", true), member("novox/mesh-controller", 346, headController[:12]+"zzzzzzzzzzzzzzzzzzzzzzzzzzzz", "main", true))},
|
|
||||||
nil, "not a whole commit"},
|
|
||||||
} {
|
|
||||||
got, unknown := siblingsFor(pull, entries, besideTheCore(), c.lines)
|
|
||||||
var said []string
|
|
||||||
for _, s := range got {
|
|
||||||
said = append(said, s.Dir+"="+s.Repository+"#"+itoa(s.Number)+"@"+s.Head+" onto "+s.Base)
|
|
||||||
}
|
|
||||||
if strings.Join(said, ";") != strings.Join(c.want, ";") {
|
|
||||||
t.Errorf("%s: siblings %v, wanted %v", c.what, said, c.want)
|
|
||||||
}
|
|
||||||
if (c.unknown == "") != (unknown == "") || !strings.Contains(unknown, c.unknown) {
|
|
||||||
t.Errorf("%s: could not learn %q, wanted %q", c.what, unknown, c.unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A pull request into a release branch has siblings into the release branch of the same name (ADR 0358 §1).
|
|
||||||
release := pull
|
|
||||||
release.Base = "feat/57"
|
|
||||||
lines := []groupsLine{group("feat/x", member("novox/mesh-host", 114, headHost, "feat/57", true),
|
|
||||||
member("novox/mesh-controller", 346, headController, "feat/57", true), member("novox/mesh-tools", 8, headLab, "main", true))}
|
|
||||||
if got, _ := siblingsFor(release, entries, besideTheCore(), lines); len(got) != 1 || got[0].Dir != "mesh-controller" {
|
|
||||||
t.Errorf("into a release branch: %+v", got)
|
|
||||||
}
|
|
||||||
// A recheck announces the pull request without its head branch: its group is the one holding it (repository, number).
|
|
||||||
pull.Head = ""
|
|
||||||
got, unknown := siblingsFor(pull, entries, besideTheCore(), lines)
|
|
||||||
if len(got) != 1 || got[0].Dir != "mesh-tools" || unknown != "" {
|
|
||||||
t.Errorf("a recheck of a group member: %+v %q", got, unknown)
|
|
||||||
}
|
|
||||||
// A pull request in no group, rechecked, is alone: no sibling, and nothing said.
|
|
||||||
pull.Number = 999
|
|
||||||
if got, unknown := siblingsFor(pull, entries, besideTheCore(), lines); got != nil || unknown != "" {
|
|
||||||
t.Errorf("a lone pull request: %+v %q", got, unknown)
|
|
||||||
}
|
|
||||||
if got, unknown := siblingsFor(pull, entries, besideTheCore(), nil); got != nil || unknown != "" {
|
|
||||||
t.Errorf("no groups at all: %+v %q", got, unknown)
|
|
||||||
}
|
|
||||||
// A mesh-delivery that names members without their pull requests cannot find the group of a recheck: it says so.
|
|
||||||
older := []groupsLine{{ID: "feat/x", Members: []string{"novox/mesh-host@111111111111 (checked)"}}}
|
|
||||||
pull.Number = 114
|
|
||||||
if got, unknown := siblingsFor(pull, entries, besideTheCore(), older); got != nil || unknown == "" {
|
|
||||||
t.Errorf("an older mesh-delivery: %+v %q", got, unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(n int) string { b, _ := json.Marshal(n); return string(b) }
|
|
||||||
|
|
||||||
// Every check asks mesh-delivery's `groups` once; the ask names the sibling's repository, head and base.
|
|
||||||
func TestEveryCheckAsksMeshDeliveryOnceAndNamesItsSiblings(t *testing.T) {
|
|
||||||
entries := theCoreOnTheGitSeat()
|
|
||||||
asked := 0
|
|
||||||
was := groupsAsked
|
|
||||||
t.Cleanup(func() { groupsAsked = was })
|
|
||||||
groupsAsked = func(_ context.Context, held bool) ([]groupsLine, error) {
|
|
||||||
asked++
|
|
||||||
if !held {
|
|
||||||
t.Error("the seat is held, yet it was asked as not held")
|
|
||||||
}
|
|
||||||
return []groupsLine{group("feat/x", member("novox/mesh-host", 114, headHost, "main", true),
|
|
||||||
member("novox/mesh-controller", 346, headController, "main", true))}, nil
|
|
||||||
}
|
|
||||||
request := link.BuildRequest{Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Base: "main", Beside: besideTheCore()}}
|
|
||||||
withSiblings(t.Context(), &request, link.PullUpdated{Owner: "novox", Repo: "mesh-host", Base: "main", Head: "feat/x"}, entries)
|
|
||||||
if asked != 1 {
|
|
||||||
t.Fatalf("mesh-delivery was asked %d times for one check", asked)
|
|
||||||
}
|
|
||||||
want := link.CheckSibling{Repository: "novox/mesh-controller", Number: 346, Head: headController, Base: "main", Dir: "mesh-controller"}
|
|
||||||
if got := request.Check.Siblings; len(got) != 1 || got[0] != want || request.Check.SiblingsUnknown != "" {
|
|
||||||
t.Fatalf("the ask names siblings %+v, unknown %q", got, request.Check.SiblingsUnknown)
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(request.Check)
|
|
||||||
if !strings.Contains(string(body), `"siblings":[{"repository":"novox/mesh-controller","number":346,"head":"`+headController+
|
|
||||||
`","base":"main","dir":"mesh-controller"}]`) {
|
|
||||||
t.Fatalf("on the wire: %s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When mesh-delivery does not answer in time (or is not held, or answered without the fields), the check reads no
|
|
||||||
// sibling and its request says it could not learn them, so a fix to mesh-delivery is still checked without it.
|
|
||||||
func TestWhenMeshDeliveryDoesNotAnswerTheCheckReadsNoSiblingAndSaysSo(t *testing.T) {
|
|
||||||
was := groupsAsked
|
|
||||||
t.Cleanup(func() { groupsAsked = was })
|
|
||||||
for _, c := range []struct {
|
|
||||||
what string
|
|
||||||
err error
|
|
||||||
held bool
|
|
||||||
}{{"it did not answer", context.DeadlineExceeded, true}, {"nothing serves it", errors.New("nothing serves mesh-delivery.groups"), true},
|
|
||||||
{"no holder on record", errors.New("no holder of mesh-delivery is on record to say it"), false}} {
|
|
||||||
entries := theCoreOnTheGitSeat()
|
|
||||||
if !c.held {
|
|
||||||
entries = entries[:4]
|
|
||||||
}
|
|
||||||
groupsAsked = func(_ context.Context, held bool) ([]groupsLine, error) {
|
|
||||||
if held != c.held {
|
|
||||||
t.Errorf("%s: held %v", c.what, held)
|
|
||||||
}
|
|
||||||
return nil, c.err
|
|
||||||
}
|
|
||||||
request := link.BuildRequest{Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Base: "main", Beside: besideTheCore()}}
|
|
||||||
withSiblings(t.Context(), &request, link.PullUpdated{Owner: "novox", Repo: "mesh-host", Base: "main", Head: "feat/x"}, entries)
|
|
||||||
if len(request.Check.Siblings) != 0 || !strings.Contains(request.Check.SiblingsUnknown, c.err.Error()) ||
|
|
||||||
!strings.Contains(request.Check.SiblingsUnknown, "reads no siblings") {
|
|
||||||
t.Errorf("%s: siblings %+v, unknown %q", c.what, request.Check.Siblings, request.Check.SiblingsUnknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A pull request for which mesh-delivery answered and named no sibling is asked exactly as before** (ADR 0382 §7):
|
|
||||||
// byte for byte the request it was without the rule.
|
|
||||||
func TestAPullRequestWithNoSiblingIsAskedByteForByteAsBefore(t *testing.T) {
|
|
||||||
entries := theCoreOnTheGitSeat()
|
|
||||||
was := groupsAsked
|
|
||||||
t.Cleanup(func() { groupsAsked = was })
|
|
||||||
for _, answer := range [][]groupsLine{nil, {group("feat/other", member("novox/mesh-controller", 346, headController, "main", true))},
|
|
||||||
{group("feat/x", member("novox/mesh-host", 114, headHost, "main", true), member("novox/hq", 3, headHq, "main", true))}} {
|
|
||||||
groupsAsked = func(context.Context, bool) ([]groupsLine, error) { return answer, nil }
|
|
||||||
build := func() link.BuildRequest {
|
|
||||||
return link.BuildRequest{ID: "build-1", Repository: "r", Ref: headHost, Check: &link.CheckRequest{Owner: "novox",
|
|
||||||
Repo: "mesh-host", Number: 114, Base: "main", Beside: besideTheCore(), Modules: []string{"mesh-host"}, Judge: "validator"}}
|
|
||||||
}
|
|
||||||
before, _ := json.Marshal(build())
|
|
||||||
after := build()
|
|
||||||
withSiblings(t.Context(), &after, link.PullUpdated{Owner: "novox", Repo: "mesh-host", Base: "main", Head: "feat/x"}, entries)
|
|
||||||
got, _ := json.Marshal(after)
|
|
||||||
if string(before) != string(got) {
|
|
||||||
t.Fatalf("the request changed:\nbefore %s\nafter %s", before, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the build seat's verdict says of its siblings reaches the forge's holder in the controller's `checked`.
|
|
||||||
func TestTheVerdictCarriesTheSiblingsAndWhereTheJudgeCameFrom(t *testing.T) {
|
|
||||||
read := []link.CheckSibling{{Repository: "novox/mesh-controller", Number: 346, Head: headController, Base: "main", BaseCommit: headHost}}
|
|
||||||
c := checkedOf(link.BuildResult{ID: "b", Ref: headHost, Checked: &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 114},
|
|
||||||
Check: &link.CheckOutcome{Verdict: "pass", Summary: "ok", Siblings: read, JudgeFrom: "sibling novox/mesh-controller#346 merge abc",
|
|
||||||
Validator: "the change's own validator"}})
|
|
||||||
if len(c.Siblings) != 1 || c.Siblings[0] != read[0] || c.JudgeFrom == "" || c.Validator == "" {
|
|
||||||
t.Fatalf("the verdict says %+v", c)
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(c)
|
|
||||||
for _, want := range []string{`"siblings":[{"repository":"novox/mesh-controller","number":346,"head":"` + headController +
|
|
||||||
`","base":"main","base_commit":"` + headHost + `"}]`, `"judge_from":"sibling novox/mesh-controller#346 merge abc"`} {
|
|
||||||
if !strings.Contains(string(body), want) {
|
|
||||||
t.Fatalf("the verdict lacks %s:\n%s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unknown := checkedOf(link.BuildResult{ID: "b", Check: &link.CheckOutcome{Verdict: "pass", SiblingsUnknown: "mesh-delivery did not answer"}})
|
|
||||||
if body, _ := json.Marshal(unknown); !strings.Contains(string(body), `"siblings_unknown":"mesh-delivery did not answer"`) ||
|
|
||||||
strings.Contains(string(body), `"siblings":`) {
|
|
||||||
t.Fatalf("the verdict that could not learn them says %s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+105
-58
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -161,56 +162,77 @@ func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Wri
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
scope := pullScope(p, entries, read, edges)
|
scope := pullScope(p, entries, read, edges)
|
||||||
// **A cycle of edges fails the check, naming every module and edge** (novox/hq ADR 0419 clause 6), a lone pull
|
// **The merge gate's checks stop** (novox/hq ADR 0503 §3, §5; issue 1299): the checker judges every repository whose
|
||||||
// request's as a group's composed check is: no order exists for what it changes.
|
// trunk requires `mesh/check`. A repository whose trunk requires `mesh/repo-check` (hq), whatever else it requires
|
||||||
alone := orderMember{ID: p.Owner + "/" + p.Repo + "@" + twelve(p.Commit), Repository: p.Owner + "/" + p.Repo, Base: p.Base,
|
// (ADR 0513 §4), has its own merge-check.sh asked of build-agent, and said with that layer alone; any other is asked
|
||||||
Head: p.Commit, Number: p.Number, Paths: p.Paths, PathsTruncated: p.PathsTruncated, Removed: p.Removed, BaseSHA: p.BaseSHA}
|
// nothing and said nothing. What the forge's protection requires is read, never listed (lead decision L11).
|
||||||
if failing, cyclic, err := cycleVerdict(ctx, "", []orderMember{alone},
|
// Mesh-config is still told what the head declares (ADR 0502), whatever judges it.
|
||||||
map[string]orderReach{alone.ID: {Moved: scope.Modules}}, edges, entries); err != nil {
|
this := pullAtOf(p)
|
||||||
return "", err
|
if this.Truncated {
|
||||||
} else if cyclic {
|
this.Paths, this.Truncated = truncatedPaths(entries, p.Owner, p.Repo, scope.Modules), false
|
||||||
failing.Commit, failing.Trunk = p.Commit, twelve(p.BaseSHA)
|
}
|
||||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s\n", p.Owner, p.Repo, p.Number, p.Commit, failing.Summary)
|
if scope.gated() || holdsModules(entries, p.Owner, p.Repo) {
|
||||||
sayChecked(ctx, w, failing)
|
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 "", nil
|
||||||
}
|
}
|
||||||
// **A member of a group whose gate is the composed check's runs its repository's check alone** (novox/hq ADR 0403
|
return f.repoCheckAlone(ctx, w, p, scope, entries)
|
||||||
// clause 1): mesh-delivery is asked once, and says so on the group's line. Told nothing, the pull request is
|
}
|
||||||
// checked alone, gate included (clause 6).
|
|
||||||
lines, groupsErr := groupsAsked(ctx, deliverySeatHeld(entries))
|
// asksRepoCheck says what a trunk's protection has the controller ask: its repository's own merge-check.sh whenever the
|
||||||
composed := groupsErr == nil && composedMember(p, lines)
|
// trunk requires `mesh/repo-check`, whatever else it requires (ADR 0513 §4: a trunk requiring both has the checker judge
|
||||||
// The change plan of the commit at hand (ADR 0238): what a merge of it would build and send, posted
|
// `mesh/check` and the repository check asked beside it), and nothing otherwise.
|
||||||
// with the verdict whatever the verdict is.
|
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) {
|
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)
|
u, err := inv.UpgradeOf(ctx, module)
|
||||||
return u, err == nil
|
return u, err == nil
|
||||||
})
|
})
|
||||||
fmt.Fprint(w, planText(plan))
|
fmt.Fprint(w, planText(plan))
|
||||||
direct := directVerdict(p, plan)
|
direct := directVerdict(p, plan)
|
||||||
if composed {
|
|
||||||
scope.Modules, scope.Dependents, scope.New, scope.Manifests = nil, nil, nil, nil
|
|
||||||
composedGate(&direct)
|
|
||||||
}
|
|
||||||
switch {
|
switch {
|
||||||
case !scope.gated() && !scope.Mesh:
|
case !scope.Mesh:
|
||||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n",
|
direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: "the repository is not the mesh's: nothing run"}
|
||||||
p.Owner, p.Repo, p.Number, p.Commit, noModuleTouched)
|
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)
|
sayChecked(ctx, w, direct)
|
||||||
return "", nil
|
return "", nil
|
||||||
case !scope.gated() && p.MergeCheckSaid && !p.MergeCheck:
|
case p.MergeCheckSaid && !p.MergeCheck:
|
||||||
direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: noMergeCheck}
|
direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: noMergeCheck}
|
||||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s; %s\n", p.Owner, p.Repo, p.Number, p.Commit, noModuleTouched, noMergeCheck)
|
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s\n", p.Owner, p.Repo, p.Number, p.Commit, noMergeCheck)
|
||||||
sayChecked(ctx, w, direct)
|
sayChecked(ctx, w, direct)
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
request, err := checkRequestFor(ctx, f.open, p, scope, entries)
|
request, err := checkRequestFor(ctx, f.open, p, checkScope{Mesh: true, From: scope.From}, entries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
request.Check.Plan = &plan
|
request.Check.Plan = &plan
|
||||||
request.Check.Composed = composed
|
|
||||||
// **The group's other open pull requests are read beside it** (novox/hq ADR 0382 §1).
|
|
||||||
siblingsFrom(&request, p, entries, lines, groupsErr)
|
|
||||||
seat := buildSeatHeld(ctx)
|
seat := buildSeatHeld(ctx)
|
||||||
ask, err := askOverOn(seat)
|
ask, err := askOverOn(seat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,23 +242,42 @@ func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Wri
|
|||||||
if err := ask.Ask(ctx, request); err != nil {
|
if err := ask.Ask(ctx, request); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
// **A newer request replaces the one still waiting, and a newer key stops the one running** (novox/hq ADR 0406 §3, §4,
|
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,
|
||||||
// issue 974): the check queue keeps one request a pull request, and a running check watches its key. Nothing is asked
|
p.Commit, seat, request.ID)
|
||||||
// of the forge here (issue 726).
|
|
||||||
what := "its own merge-check.sh alone: " + noModuleTouched
|
|
||||||
if composed {
|
|
||||||
what = "its own merge-check.sh alone: the gate is its group's composed check's"
|
|
||||||
}
|
|
||||||
if scope.gated() {
|
|
||||||
what = fmt.Sprintf("the gate over %s (a merge would build %d module(s) in %d tier(s))",
|
|
||||||
strings.Join(append(append([]string{}, scope.Modules...), prefixedAll("new:", scope.New)...), ", "),
|
|
||||||
scope.Width, scope.Tiers)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): asked %s to check it before it merges — %s — as %s\n", p.Owner, p.Repo, p.Number,
|
|
||||||
p.Commit, seat, what, request.ID)
|
|
||||||
return request.ID, nil
|
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).
|
// 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"
|
const composedWords = "judged by its group's composed check"
|
||||||
|
|
||||||
@@ -365,15 +406,6 @@ func trunkOf(s inventory.Source) string {
|
|||||||
return "main"
|
return "main"
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceName is the name of a recorded repository, a path on the git seat or a URL: novox/mesh-host → mesh-host.
|
|
||||||
func sourceName(repository string) string {
|
|
||||||
parts := strings.Split(strings.Trim(strings.TrimSuffix(repository, ".git"), "/"), "/")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return parts[len(parts)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// namedForTheCheck are the builds earlier checks of the same pull request at the same head named, read once for the
|
// 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
|
// 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
|
// still the same. Another pull request's build, or one of an earlier head, is never kept. None when they cannot be
|
||||||
@@ -594,11 +626,13 @@ func checkedOf(result link.BuildResult) link.Checked {
|
|||||||
// A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first.
|
// A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first.
|
||||||
if g := result.Checked; g.Group != "" {
|
if g := result.Checked; g.Group != "" {
|
||||||
c.Group = 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,
|
c.Members = append(c.Members, link.CheckedMember{Owner: g.Owner, Repo: g.Repo, Number: g.Number,
|
||||||
Commit: result.Ref, Trunk: c.Trunk})
|
Commit: result.Ref, Trunk: firstTrunk(c.Trunk, g.Trunk)})
|
||||||
for _, m := range g.Members {
|
for _, m := range g.Members {
|
||||||
c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number,
|
c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number,
|
||||||
Commit: m.Ref, Trunk: memberTrunk(result.Check, m)})
|
Commit: m.Ref, Trunk: firstTrunk(memberTrunk(result.Check, m), m.Trunk)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,6 +661,16 @@ func checkedOf(result link.BuildResult) link.Checked {
|
|||||||
return c
|
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
|
// 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.
|
// when it said none.
|
||||||
func memberTrunk(outcome *link.CheckOutcome, m link.GroupMember) string {
|
func memberTrunk(outcome *link.CheckOutcome, m link.GroupMember) string {
|
||||||
@@ -651,6 +695,9 @@ func prefixedAll(prefix string, items []string) []string {
|
|||||||
|
|
||||||
// sayChecked says a merge check's verdict on the bus, where the forge's holder hears it.
|
// 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) {
|
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"
|
repo := "none"
|
||||||
if c.RepoCheck != nil {
|
if c.RepoCheck != nil {
|
||||||
repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary
|
repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary
|
||||||
@@ -659,8 +706,8 @@ func sayChecked(ctx context.Context, w io.Writer, c link.Checked) {
|
|||||||
if c.Trunk != "" {
|
if c.Trunk != "" {
|
||||||
judged = " merged into trunk " + c.Trunk
|
judged = " merged into trunk " + c.Trunk
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%s: %s/%s#%d at %.8s%s checked on %s: gate %s — %s; repository %s\n", c.ID, c.Owner, c.Repo, c.Number,
|
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), strings.ToUpper(c.Verdict), c.Summary, repo)
|
c.Commit, judged, orSomewhere(c.On), repo)
|
||||||
if checkEvents == nil {
|
if checkEvents == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Acceptance 5 of milestone 2 (novox/hq ADR 0406 §3, issue 974), live, with the store and a real bus: two merges into one
|
|
||||||
// repository within a minute, with two open pull requests, leave each pull request with exactly one queued check, by the
|
|
||||||
// stream's rule. Each merge moves the trunk, and mesh-delivery asks the controller to check both open pull requests again
|
|
||||||
// against it (`delivery check`, which is the controller's own path for a head announced); nothing moves a request, nothing
|
|
||||||
// asks the forge, and the queue is read back whole.
|
|
||||||
func TestTwoMergesWithinAMinuteLeaveEachOpenPullRequestOneQueuedCheck(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
js, _ := aBuildQueue(t)
|
|
||||||
servingBus.Store(js)
|
|
||||||
t.Cleanup(func() { servingBus.Store(nil) })
|
|
||||||
holdTheDeliverySeat(t, open)
|
|
||||||
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(`[]`), nil
|
|
||||||
}
|
|
||||||
// The repository the two pull requests are into, as a module the mesh builds from it.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
heads := map[int]string{7: "7777777777777777777777777777777777777777", 8: "8888888888888888888888888888888888888888"}
|
|
||||||
check := func(number int, trunk string) {
|
|
||||||
t.Helper()
|
|
||||||
p := orderMember{ID: fmt.Sprintf("novox/mesh-controller@%.12s", heads[number]), Repository: "novox/mesh-controller",
|
|
||||||
Base: "main", Head: heads[number], Number: number, Paths: []string{"main.go"}, ModuleDirsSaid: true,
|
|
||||||
BaseSHA: trunk, Ready: number == 8}.pull()
|
|
||||||
p.MergeCheck, p.MergeCheckSaid = true, true
|
|
||||||
if err := (following{open: open, oneShot: true}).PullUpdated(ctx, p); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Announced against the trunk as it stands, then asked again after each of two merges.
|
|
||||||
for _, trunk := range []string{"1111111111111111111111111111111111111111", "2222222222222222222222222222222222222222",
|
|
||||||
"3333333333333333333333333333333333333333"} {
|
|
||||||
check(7, trunk)
|
|
||||||
check(8, trunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
seen := map[int]string{}
|
|
||||||
for _, a := range q.Asks {
|
|
||||||
c := a.Request.Check
|
|
||||||
if c == nil {
|
|
||||||
t.Fatalf("%s is no check", a.ID)
|
|
||||||
}
|
|
||||||
if _, twice := seen[c.Number]; twice {
|
|
||||||
t.Errorf("pull request %d has two queued checks", c.Number)
|
|
||||||
}
|
|
||||||
seen[c.Number] = c.Trunk
|
|
||||||
if a.State != link.AskWaiting {
|
|
||||||
t.Errorf("%s is %s", a.ID, a.State)
|
|
||||||
}
|
|
||||||
if want := "3333333333333333333333333333333333333333"; c.Trunk != want {
|
|
||||||
t.Errorf("pull request %d is queued against %.8s; want the newest trunk %.8s", c.Number, c.Trunk, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(seen) != 2 {
|
|
||||||
t.Fatalf("queued checks for %v; want one for each of the two open pull requests", seen)
|
|
||||||
}
|
|
||||||
// By the streams' own count: the ready one on its stream, the other on its.
|
|
||||||
for stream, want := range map[string]uint64{broker.ChecksReadyStream: 1, broker.ChecksOtherStream: 1} {
|
|
||||||
info, err := js.Context().StreamInfo(stream)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if info.State.Msgs != want {
|
|
||||||
t.Errorf("%s holds %d request(s); want %d", stream, info.State.Msgs, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,6 +250,31 @@ func TestAVerdictNamesTheTrunkCommitItJudged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **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
|
// **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
|
// 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.
|
// merge commit a check makes.
|
||||||
@@ -291,3 +316,21 @@ func TestARecheckedHeadsDirectVerdictNamesItsTrunk(t *testing.T) {
|
|||||||
|
|
||||||
// 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
|
// 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.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -239,7 +239,11 @@ func markUnresolvable(err error) error {
|
|||||||
// still names anything foreseen is refused, said, and never sent.
|
// still names anything foreseen is refused, said, and never sent.
|
||||||
func composeSend(ctx context.Context, open *stores, node string) (sendable, error) {
|
func composeSend(ctx context.Context, open *stores, node string) (sendable, error) {
|
||||||
declared, _, err := composeSendValidated(ctx, open, node)
|
declared, _, err := composeSendValidated(ctx, open, node)
|
||||||
return declared, err
|
if err != nil {
|
||||||
|
return declared, err
|
||||||
|
}
|
||||||
|
// Never a declaration naming an image the store does not hold (novox/hq issue 1742): that module is left out.
|
||||||
|
return withoutMissingImages(ctx, open, node, declared), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// composeSendValidated is composeSend and what the node-engine's validator refuses of the declaration it composed, for the
|
// composeSendValidated is composeSend and what the node-engine's validator refuses of the declaration it composed, for the
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A member of a group whose gate is the composed check's is asked of the build seat with no module: its repository's
|
|
||||||
// check alone (novox/hq ADR 0403 clause 1). With no word from mesh-delivery, or one that does not say the gate is
|
|
||||||
// composed, it is checked alone with its gate (clause 6).
|
|
||||||
|
|
||||||
func askedForMember(t *testing.T, answer []groupsLine, answerErr error) link.CheckRequest {
|
|
||||||
t.Helper()
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
js, _ := aBuildQueue(t)
|
|
||||||
servingBus.Store(js)
|
|
||||||
t.Cleanup(func() { servingBus.Store(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)
|
|
||||||
}
|
|
||||||
was := groupsAsked
|
|
||||||
groupsAsked = func(context.Context, bool) ([]groupsLine, error) { return answer, answerErr }
|
|
||||||
t.Cleanup(func() { groupsAsked = was })
|
|
||||||
p := link.PullUpdated{Owner: "novox", Repo: "mesh-controller", Number: 346, Base: "main", Head: "feat/x", Commit: headController,
|
|
||||||
Paths: []string{"main.go"}, ModuleDirsSaid: true, MergeCheck: true, MergeCheckSaid: true}
|
|
||||||
if err := (following{open: open, oneShot: true}).PullUpdated(ctx, p); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
|
||||||
if err != nil || len(q.Asks) != 1 {
|
|
||||||
t.Fatalf("asks %d: %v", len(q.Asks), err)
|
|
||||||
}
|
|
||||||
return *q.Asks[0].Request.Check
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAComposedMembersCheckIsItsRepositoryChecksAlone(t *testing.T) {
|
|
||||||
line := group("feat/x", member("novox/mesh-controller", 346, headController, "main", true), member("novox/mesh-catalog", 350, headHost, "main", true))
|
|
||||||
line.ComposedGate = true
|
|
||||||
c := askedForMember(t, []groupsLine{line}, nil)
|
|
||||||
if len(c.Modules) != 0 || len(c.New) != 0 || len(c.Dependents) != 0 || !c.Composed {
|
|
||||||
t.Fatalf("the member's check is asked as %+v; want no module and composed", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAMemberOfAGroupOfAnOlderMeshDeliveryKeepsItsOwnGate(t *testing.T) {
|
|
||||||
line := group("feat/x", member("novox/mesh-controller", 346, headController, "main", true), member("novox/mesh-catalog", 350, headHost, "main", true))
|
|
||||||
c := askedForMember(t, []groupsLine{line}, nil)
|
|
||||||
if !strings.Contains(strings.Join(c.Modules, ","), "mesh-controller") || c.Composed {
|
|
||||||
t.Fatalf("an older mesh-delivery's member is asked as %+v; want its own gate", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAMemberWhoseGroupCannotBeLearnedIsCheckedAloneWithItsGate(t *testing.T) {
|
|
||||||
c := askedForMember(t, nil, context.DeadlineExceeded)
|
|
||||||
if !strings.Contains(strings.Join(c.Modules, ","), "mesh-controller") || c.Composed ||
|
|
||||||
!strings.Contains(c.SiblingsUnknown, "reads no siblings") {
|
|
||||||
t.Fatalf("the member is asked as %+v; want its gate and why its siblings are unknown", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"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
|
// 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.
|
// (novox/hq ADR 0403 clauses 1 and 7). Pure: no store, no bus.
|
||||||
|
|
||||||
// A member of a group whose line says composed_gate is a composed member; one whose group says nothing of it (an older
|
// The heads of a group's members, by repository.
|
||||||
// mesh-delivery), one of a group of a single pull request and a lone pull request are not.
|
const (
|
||||||
func TestAMemberOfAComposedGroupIsKnownByTheGroupsLine(t *testing.T) {
|
headHost = "1111111111111111111111111111111111111111"
|
||||||
two := func(composed bool) groupsLine {
|
headController = "2222222222222222222222222222222222222222"
|
||||||
l := group("feat/x", member("novox/mesh-host", 114, headHost, "main", true), member("novox/mesh-controller", 346, headController, "main", true))
|
)
|
||||||
l.ComposedGate = composed
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
p := link.PullUpdated{Owner: "novox", Repo: "mesh-host", Number: 114, Base: "main", Head: "feat/x", Commit: headHost}
|
|
||||||
recheck := link.PullUpdated{Owner: "novox", Repo: "mesh-host", Number: 114, Base: "main", Commit: headHost}
|
|
||||||
for name, c := range map[string]struct {
|
|
||||||
p link.PullUpdated
|
|
||||||
lines []groupsLine
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
"composed": {p, []groupsLine{two(true)}, true},
|
|
||||||
"a recheck, by its number": {recheck, []groupsLine{two(true)}, true},
|
|
||||||
"an older mesh-delivery": {p, []groupsLine{two(false)}, false},
|
|
||||||
"another group": {p, []groupsLine{group("feat/y", member("novox/mesh-host", 1, headHost, "main", true))}, false},
|
|
||||||
"no group": {p, nil, false},
|
|
||||||
} {
|
|
||||||
if got := composedMember(c.p, c.lines); got != c.want {
|
|
||||||
t.Errorf("%s: composed %v, want %v", name, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alone := group("feat/x", member("novox/mesh-host", 114, headHost, "main", true))
|
|
||||||
alone.ComposedGate = true
|
|
||||||
if composedMember(p, []groupsLine{alone}) {
|
|
||||||
t.Error("a group of a single pull request is composed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A composed member's verdict never says its gate passed: the gate layer, and the verdict that is the gate's, read
|
// 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.
|
// "composed"; a failure or an error stays what it was.
|
||||||
@@ -88,49 +59,3 @@ func TestTheComposedVerdictCarriesEveryMember(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Members whose manifests order their modules in a cycle fail the composed check, naming every module and edge, and no
|
|
||||||
// ask is made of the build seat.
|
|
||||||
func TestAGroupInACycleFailsItsComposedCheckWithoutABuild(t *testing.T) {
|
|
||||||
members := []orderMember{
|
|
||||||
{ID: "novox/x@" + headHost[:12], Repository: "novox/x", Base: "main", Head: headHost, Number: 7, Paths: []string{"module.json"}},
|
|
||||||
{ID: "novox/y@" + headController[:12], Repository: "novox/y", Base: "main", Head: headController, Number: 9, Paths: []string{"module.json"}},
|
|
||||||
}
|
|
||||||
reach := map[string]orderReach{members[0].ID: {Moved: []string{"mx"}}, members[1].ID: {Moved: []string{"my"}}}
|
|
||||||
was, wasParent := manifestAt, parentOf
|
|
||||||
t.Cleanup(func() { manifestAt, parentOf = was, wasParent })
|
|
||||||
parentOf = func(context.Context, string, string, string) (string, error) { return "", nil }
|
|
||||||
manifestAt = func(_ context.Context, _, repo, _, ref string) (catalogue.Manifest, bool, error) {
|
|
||||||
if ref == "main" {
|
|
||||||
return catalogue.Manifest{}, false, nil // what the pull requests merge into holds no such manifest
|
|
||||||
}
|
|
||||||
if repo == "x" {
|
|
||||||
return catalogue.Manifest{Module: "mx", DefinesSeats: []catalogue.SeatDeclaration{{Name: "s", Serves: []catalogue.Verb{{Name: "a"}}}},
|
|
||||||
Invokes: []string{"seat:s.b"}}, true, nil
|
|
||||||
}
|
|
||||||
return catalogue.Manifest{Module: "my", DefinesSeats: []catalogue.SeatDeclaration{{Name: "s", Serves: []catalogue.Verb{{Name: "b"}}}},
|
|
||||||
Invokes: []string{"seat:s.a"}}, true, nil
|
|
||||||
}
|
|
||||||
c, cyclic, err := cycleVerdict(context.Background(), "feat/cycle", members, reach, []inventory.Edge(nil), nil)
|
|
||||||
if err != nil || !cyclic {
|
|
||||||
t.Fatalf("no cycle found: %v", err)
|
|
||||||
}
|
|
||||||
if c.Verdict != "fail" || c.Group != "feat/cycle" || len(c.Members) != 2 || c.Gate == nil || c.Gate.Verdict != "fail" {
|
|
||||||
t.Fatalf("the verdict is %+v", c)
|
|
||||||
}
|
|
||||||
for _, w := range []string{"mx", "my", "s.a", "s.b"} {
|
|
||||||
if !strings.Contains(c.Summary, w) {
|
|
||||||
t.Errorf("the cycle does not name %s: %s", w, c.Summary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Members with no edge between them are no cycle.
|
|
||||||
manifestsReadMu.Lock()
|
|
||||||
clear(manifestsRead)
|
|
||||||
manifestsReadMu.Unlock()
|
|
||||||
manifestAt = func(context.Context, string, string, string, string) (catalogue.Manifest, bool, error) {
|
|
||||||
return catalogue.Manifest{}, false, nil
|
|
||||||
}
|
|
||||||
if _, cyclic, err := cycleVerdict(context.Background(), "feat/cycle", members, reach, nil, nil); err != nil || cyclic {
|
|
||||||
t.Fatalf("a cycle where there is none: %v %v", cyclic, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1840/AC5 (ADR 0507 §5): the source of the declared-packages mirror composes each node and names, for a package two modules
|
||||||
|
// declare and one declared absent, the modules that declare it; the same records give the same bytes on a second pass, so
|
||||||
|
// the mirror writes nothing; a node with no package has a key that says so.
|
||||||
|
func TestTheDeclaredPackagesMirrorNamesWhatEachNodesModulesDeclare(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, catalogue.Manifest{Module: "viewer", Version: "1", Resources: []map[string]any{
|
||||||
|
{"id": "pkg-curl", "type": "package", "package": "curl"},
|
||||||
|
{"id": "pkg-nano", "type": "package", "package": "nano", "absent": true}}})
|
||||||
|
register(t, open, catalogue.Manifest{Module: "fetcher", Version: "1", Resources: []map[string]any{
|
||||||
|
{"id": "pkg-curl", "type": "package", "package": "curl"}}})
|
||||||
|
for _, module := range []string{"viewer", "fetcher"} {
|
||||||
|
if _, err := assign(ctx, open, "anchor", module); err != nil {
|
||||||
|
t.Fatalf("assign anchor %s: %v", module, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source := declaredPackagesMirror(open)
|
||||||
|
first, err := source(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got inventory.DeclaredPackagesMirrored
|
||||||
|
if err := json.Unmarshal(first["anchor"], &got); err != nil {
|
||||||
|
t.Fatalf("anchor has no value: %v", err)
|
||||||
|
}
|
||||||
|
// Every node also runs the mesh's own modules, which declare packages of their own: curl and nano are looked
|
||||||
|
// up by name, and no package names viewer or fetcher but those they declare.
|
||||||
|
byName := map[string]inventory.PackageDeclared{}
|
||||||
|
for _, p := range got.Packages {
|
||||||
|
byName[p.Package] = p
|
||||||
|
}
|
||||||
|
same := func(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, p := range []inventory.PackageDeclared{{Package: "curl", Modules: []string{"fetcher", "viewer"}},
|
||||||
|
{Package: "nano", Modules: []string{"viewer"}, Absent: true}} {
|
||||||
|
if g, ok := byName[p.Package]; !ok || g.Absent != p.Absent || !same(g.Modules, p.Modules) {
|
||||||
|
t.Errorf("anchor declares %s as %+v, want %+v (all: %+v)", p.Package, g, p, got.Packages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range got.Packages {
|
||||||
|
if p.Package == "curl" || p.Package == "nano" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range p.Modules {
|
||||||
|
if m == "viewer" || m == "fetcher" {
|
||||||
|
t.Errorf("%s is said to be declared by %s, which does not declare it", p.Package, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var laptop inventory.DeclaredPackagesMirrored
|
||||||
|
if err := json.Unmarshal(first["laptop"], &laptop); err != nil || laptop.Node != "laptop" || laptop.Packages == nil {
|
||||||
|
t.Errorf("a node assigned neither module has no value of its own: %s (%v)", first["laptop"], err)
|
||||||
|
}
|
||||||
|
for _, p := range laptop.Packages {
|
||||||
|
if p.Package == "curl" || p.Package == "nano" {
|
||||||
|
t.Errorf("laptop is assigned neither module and declares %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
second, err := source(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for node, value := range first {
|
||||||
|
if string(second[node]) != string(value) {
|
||||||
|
t.Errorf("the value of %s changed between two passes over the same records:\n%s\n%s", node, value, second[node])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-153
@@ -815,16 +815,14 @@ func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[str
|
|||||||
// delivery walks [-n 50] [--plan <id>]
|
// delivery walks [-n 50] [--plan <id>]
|
||||||
func deliveryCommand(ctx context.Context, args []string) error {
|
func deliveryCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("delivery plan|order|check|go|stop|walks")
|
return errors.New("delivery order|check|go|stop|walks")
|
||||||
}
|
}
|
||||||
sub, rest := args[0], args[1:]
|
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")
|
||||||
|
}
|
||||||
set := flag.NewFlagSet("delivery "+sub, flag.ContinueOnError)
|
set := flag.NewFlagSet("delivery "+sub, flag.ContinueOnError)
|
||||||
repository := set.String("repository", "", "owner/repository")
|
|
||||||
base := set.String("base", "main", "the branch it merges into")
|
|
||||||
head := set.String("head", "", "the commit at hand")
|
|
||||||
paths := set.String("paths", "", "the files it changes, comma-separated")
|
|
||||||
moduleDirs := set.String("module-dirs", "", "the directories holding a module.json at the head, comma-separated")
|
|
||||||
removed := set.String("removed", "", "the files it deletes, comma-separated")
|
|
||||||
membersJSON := set.String("members", "", "a group's members, as JSON")
|
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 "+
|
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)")
|
"too long for an argument (novox/hq issue 1272)")
|
||||||
@@ -872,19 +870,6 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
|||||||
return enc.Encode(v)
|
return enc.Encode(v)
|
||||||
}
|
}
|
||||||
switch sub {
|
switch sub {
|
||||||
case "plan":
|
|
||||||
if *repository == "" || *head == "" && *paths == "" {
|
|
||||||
return errors.New("delivery plan --repository owner/repo --head <commit> --paths a,b")
|
|
||||||
}
|
|
||||||
m := changeMember(*repository, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs))
|
|
||||||
entries, read, edges, err := theGraph(ctx, inv)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return answer(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) {
|
|
||||||
u, err := inv.UpgradeOf(ctx, module)
|
|
||||||
return u, err == nil
|
|
||||||
}))
|
|
||||||
case "order":
|
case "order":
|
||||||
if len(members) == 0 {
|
if len(members) == 0 {
|
||||||
return errors.New("delivery order --members <json>: a group has members")
|
return errors.New("delivery order --members <json>: a group has members")
|
||||||
@@ -914,11 +899,9 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
|||||||
if *group == "" || len(members) < 2 {
|
if *group == "" || len(members) < 2 {
|
||||||
return errors.New("delivery check --group <id> --members <json> (two heads or more), or --members <one head>")
|
return errors.New("delivery check --group <id> --members <json> (two heads or more), or --members <one head>")
|
||||||
}
|
}
|
||||||
id, err := askGroupCheck(ctx, open, *group, members)
|
// **The group's composed check is the checker's** (novox/hq ADR 0503 §3; issue 1299, C2): nothing is asked of
|
||||||
if err != nil {
|
// build-agent, and the answer says who judges it.
|
||||||
return err
|
return answer(groupCheckAnswer(*group))
|
||||||
}
|
|
||||||
return answer(map[string]any{"asked": id, "group": *group})
|
|
||||||
case "go":
|
case "go":
|
||||||
if len(positionals) != 1 {
|
if len(positionals) != 1 {
|
||||||
return errors.New("delivery go <plan> [--by <who>] [--why <text>]")
|
return errors.New("delivery go <plan> [--by <who>] [--why <text>]")
|
||||||
@@ -1093,38 +1076,6 @@ func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry,
|
|||||||
return entries, read, edges, nil
|
return entries, read, edges, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cycleVerdict is the failing verdict of a group whose modules are ordered in a cycle, and whether there is one: the
|
|
||||||
// group's composed check, said without a build. The manifests' change is read from the forge; an answer it cannot give
|
|
||||||
// is an error, never read as no cycle.
|
|
||||||
func cycleVerdict(ctx context.Context, group string, members []orderMember, reach map[string]orderReach,
|
|
||||||
edges []inventory.Edge, entries []inventory.Entry) (link.Checked, bool, error) {
|
|
||||||
changes, err := changesOf(ctx, changeSourcesOf(members, entries, reach))
|
|
||||||
if err != nil {
|
|
||||||
return link.Checked{}, false, fmt.Errorf("what the group's members say of their manifests cannot be read: %w", err)
|
|
||||||
}
|
|
||||||
var moved []string
|
|
||||||
for _, m := range members {
|
|
||||||
moved = appendNew(moved, reach[m.ID].Moved...)
|
|
||||||
}
|
|
||||||
said := orderEdges(moved, changes, edges).CycleSaid()
|
|
||||||
if said == "" {
|
|
||||||
return link.Checked{}, false, nil
|
|
||||||
}
|
|
||||||
first := members[0].pull()
|
|
||||||
c := link.Checked{ID: link.NewBuildID(time.Now()), Owner: first.Owner, Repo: first.Repo, Number: first.Number,
|
|
||||||
Commit: first.Commit, Verdict: "fail", Summary: said, Report: said, Group: group,
|
|
||||||
Gate: &link.CheckLayer{Verdict: "fail", Summary: said}}
|
|
||||||
for _, m := range members {
|
|
||||||
if group == "" {
|
|
||||||
break // a lone pull request's verdict names no group and no member
|
|
||||||
}
|
|
||||||
p := m.pull()
|
|
||||||
c.Members = append(c.Members, link.CheckedMember{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit,
|
|
||||||
Trunk: twelve(p.BaseSHA)})
|
|
||||||
}
|
|
||||||
return c, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// twelve is a commit as twelve characters.
|
// twelve is a commit as twelve characters.
|
||||||
func twelve(c string) string {
|
func twelve(c string) string {
|
||||||
if len(c) > 12 {
|
if len(c) > 12 {
|
||||||
@@ -1133,93 +1084,6 @@ func twelve(c string) string {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupPrimary is the head a group's composed check is run from: the one moving the controller, which then
|
|
||||||
// judges the group by itself; else the one moving the node-engine, whose validator judges; else the first.
|
|
||||||
func groupPrimary(members []orderMember, reach map[string]orderReach) int {
|
|
||||||
for _, want := range []string{"mesh-controller", "mesh-host"} {
|
|
||||||
for i, m := range members {
|
|
||||||
if slices.Contains(reach[m.ID].Moved, want) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// askGroupCheck asks the build seat for a group's composed check: every head laid over the mesh in turn,
|
|
||||||
// judged as one future state (novox/hq ADR 0239). The verdict comes back as `checked` with the group's id,
|
|
||||||
// for mesh-delivery; the forge's holder sets no status from it.
|
|
||||||
func askGroupCheck(ctx context.Context, open *stores, group string, members []orderMember) (string, error) {
|
|
||||||
entries, read, edges, err := theGraph(ctx, open.inventory)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
reach := reachOfMembers(members, entries, read, edges)
|
|
||||||
// **A cycle of edges fails the group's composed check, naming every module and edge** (novox/hq ADR 0419 clause 6),
|
|
||||||
// before the build seat is asked: no composition of the heads has an order to be sent in.
|
|
||||||
if failing, cyclic, err := cycleVerdict(ctx, group, members, reach, edges, entries); err != nil {
|
|
||||||
return "", err
|
|
||||||
} else if cyclic {
|
|
||||||
sayChecked(ctx, os.Stderr, failing)
|
|
||||||
return failing.ID, nil
|
|
||||||
}
|
|
||||||
primary := groupPrimary(members, reach)
|
|
||||||
p := members[primary].pull()
|
|
||||||
scope := pullScope(p, entries, read, edges)
|
|
||||||
// The gate runs over what any member moves; a judge from the primary alone.
|
|
||||||
for _, m := range members {
|
|
||||||
r := reach[m.ID]
|
|
||||||
scope.Modules = appendNew(scope.Modules, r.Moved...)
|
|
||||||
scope.Dependents = appendNew(scope.Dependents, r.Dependents...)
|
|
||||||
}
|
|
||||||
request, err := checkRequestFor(ctx, open, p, scope, entries)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
request.Check.Group = group
|
|
||||||
// A group's check is queued as ready when any member's pull request is marked ready.
|
|
||||||
request.Check.Ready = slices.ContainsFunc(members, func(m orderMember) bool { return m.Ready })
|
|
||||||
world, err := restOfTheMesh(ctx, open, shelfOf(entries), "")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
for i, m := range members {
|
|
||||||
if i == primary {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mp := m.pull()
|
|
||||||
// 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: mp.Owner + "/" + mp.Repo}
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.Provided && sameRepository(e.Source.Repository, link.SourceMoved{Owner: mp.Owner, Repo: mp.Repo}) {
|
|
||||||
source = e.Source
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
url := source.Repository
|
|
||||||
if source.Seat != "" {
|
|
||||||
url, err = clonedFromSeat(world, source.Seat, source.Repository)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%s cannot be cloned for the group's check: %w", m.ID, err)
|
|
||||||
}
|
|
||||||
request.Check.Members = append(request.Check.Members, link.GroupMember{Owner: mp.Owner, Repo: mp.Repo,
|
|
||||||
Number: mp.Number, Repository: url, Ref: mp.Commit, Paths: mp.Paths, Base: mp.Base})
|
|
||||||
}
|
|
||||||
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(os.Stderr, "group %s: asked %s to check %d head(s) composed together, as %s\n", group, seat,
|
|
||||||
len(members), request.ID)
|
|
||||||
return request.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendNew appends what is not there yet.
|
// appendNew appends what is not there yet.
|
||||||
func appendNew(to []string, items ...string) []string {
|
func appendNew(to []string, items ...string) []string {
|
||||||
for _, i := range items {
|
for _, i := range items {
|
||||||
@@ -1230,15 +1094,6 @@ func appendNew(to []string, items ...string) []string {
|
|||||||
return to
|
return to
|
||||||
}
|
}
|
||||||
|
|
||||||
// shelfOf is the catalogue's manifests by name.
|
|
||||||
func shelfOf(entries []inventory.Entry) map[string]catalogue.Manifest {
|
|
||||||
shelf := map[string]catalogue.Manifest{}
|
|
||||||
for _, e := range entries {
|
|
||||||
shelf[e.Manifest.Module] = e.Manifest
|
|
||||||
}
|
|
||||||
return shelf
|
|
||||||
}
|
|
||||||
|
|
||||||
// sayPlanMoved says a walk kept in a new state as the controller's `plan-moved`, the plan whole: what the
|
// sayPlanMoved says a walk kept in a new state as the controller's `plan-moved`, the plan whole: what the
|
||||||
// delivery it walks reads its steps from. Never in the way of the walk: said beside it, and a save that could
|
// delivery it walks reads its steps from. Never in the way of the walk: said beside it, and a save that could
|
||||||
// not be said is logged — the delivery's owner, which also asks for the walks it follows, finds it by
|
// not be said is logged — the delivery's owner, which also asks for the walks it follows, finds it by
|
||||||
@@ -1402,3 +1257,10 @@ func recheckOneHead(member string, answer func(any) error, ask func() (string, e
|
|||||||
}
|
}
|
||||||
return answer(recheckAnswer(asked, member))
|
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"}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func TestSayCheckedWritesTheSameLineToItsWriter(t *testing.T) {
|
|||||||
var w bytes.Buffer
|
var w bytes.Buffer
|
||||||
sayChecked(context.Background(), &w, link.Checked{ID: "c1", Owner: "novox", Repo: "x", Number: 7, Commit: "0123456789abcdef",
|
sayChecked(context.Background(), &w, link.Checked{ID: "c1", Owner: "novox", Repo: "x", Number: 7, Commit: "0123456789abcdef",
|
||||||
Verdict: "pass", Summary: "fine"})
|
Verdict: "pass", Summary: "fine"})
|
||||||
want := "c1: novox/x#7 at 01234567 checked on a machine that did not say which: gate PASS — fine; repository none\n"
|
want := "c1: novox/x#7 at 01234567 checked on a machine that did not say which: repository none\n"
|
||||||
if w.String() != want {
|
if w.String() != want {
|
||||||
t.Fatalf("sayChecked said %q; want %q", w.String(), want)
|
t.Fatalf("sayChecked said %q; want %q", w.String(), want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,17 @@ type stalledLine struct {
|
|||||||
// Severity is "urgent" for a group held or half merged and a refused member (novox/hq issues 1275, 1278); empty
|
// Severity is "urgent" for a group held or half merged and a refused member (novox/hq issues 1275, 1278); empty
|
||||||
// or anything else is a warning, as before.
|
// or anything else is a warning, as before.
|
||||||
Severity string `json:"severity,omitempty"`
|
Severity string `json:"severity,omitempty"`
|
||||||
|
// Needs is "new-head" for a delivery whose cure is its author's new head (novox/hq ADR 0505 §5), Author that author
|
||||||
|
// as the operator reads it ("the author of feat/x", with the session that pushed it when its pull request names one)
|
||||||
|
// and Branch its branch. A mesh-delivery from before says none, and the words are as before.
|
||||||
|
Needs string `json:"needs,omitempty"`
|
||||||
|
Author string `json:"author,omitempty"`
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// needsNewHead is what a stalled line needs when its cure is its author's new head (ADR 0505 §5).
|
||||||
|
const needsNewHead = "new-head"
|
||||||
|
|
||||||
// severity is the condition's severity a stalled line asks: urgent when it says so, a warning otherwise.
|
// severity is the condition's severity a stalled line asks: urgent when it says so, a warning otherwise.
|
||||||
func (l stalledLine) severity() conditions.Severity {
|
func (l stalledLine) severity() conditions.Severity {
|
||||||
if l.Severity == string(conditions.Urgent) {
|
if l.Severity == string(conditions.Urgent) {
|
||||||
|
|||||||
@@ -181,32 +181,3 @@ func TestATruncatedChangeIsReadAsChangingTheManifestsOfWhatItMoves(t *testing.T)
|
|||||||
t.Fatalf("the member's change is read as %+v", src)
|
t.Fatalf("the member's change is read as %+v", src)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A lone pull request's cycle fails its check, naming every module and edge, with no group and no members.
|
|
||||||
func TestALonePullRequestInACycleFailsWithNoGroup(t *testing.T) {
|
|
||||||
was, wasParent := manifestAt, parentOf
|
|
||||||
t.Cleanup(func() { manifestAt, parentOf = was, wasParent })
|
|
||||||
manifestsReadMu.Lock()
|
|
||||||
clear(manifestsRead)
|
|
||||||
manifestsReadMu.Unlock()
|
|
||||||
const head = "3333333333333333333333333333333333333333"
|
|
||||||
parentOf = func(context.Context, string, string, string) (string, error) { return "", nil }
|
|
||||||
manifestAt = func(_ context.Context, _, _, file, ref string) (catalogue.Manifest, bool, error) {
|
|
||||||
if ref == "main" {
|
|
||||||
return catalogue.Manifest{}, false, nil
|
|
||||||
}
|
|
||||||
if file == "modules/x/module.json" {
|
|
||||||
return catalogue.Manifest{Module: "mx", DefinesSeats: []catalogue.SeatDeclaration{{Name: "s", Serves: []catalogue.Verb{{Name: "a"}}}},
|
|
||||||
Invokes: []string{"seat:s.b"}}, true, nil
|
|
||||||
}
|
|
||||||
return catalogue.Manifest{Module: "my", DefinesSeats: []catalogue.SeatDeclaration{{Name: "s", Serves: []catalogue.Verb{{Name: "b"}}}},
|
|
||||||
Invokes: []string{"seat:s.a"}}, true, nil
|
|
||||||
}
|
|
||||||
m := orderMember{ID: "novox/r@" + head[:12], Repository: "novox/r", Base: "main", Head: head, Number: 5,
|
|
||||||
Paths: []string{"modules/x/module.json", "modules/y/module.json"}}
|
|
||||||
c, cyclic, err := cycleVerdict(context.Background(), "", []orderMember{m}, map[string]orderReach{m.ID: {Moved: []string{"mx", "my"}}}, nil, nil)
|
|
||||||
if err != nil || !cyclic || c.Verdict != "fail" || c.Group != "" || len(c.Members) != 0 || !strings.Contains(c.Summary, "s.a") ||
|
|
||||||
!strings.Contains(c.Summary, "my") {
|
|
||||||
t.Fatalf("the verdict is %+v (cyclic %v, %v)", c, cyclic, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// heardOf has this mesh's own view of the plans take in the plan as the bucket holds it. A verb of the
|
||||||
|
// controller (deliveryCommand, plansCommand) opens an inventory of its own and writes the plan there; this
|
||||||
|
// mesh's inventory learns of the write from the bucket's watch, a moment later, and a loaded machine makes the
|
||||||
|
// moment long enough for advanceHeld to read the plan as it was, still waiting for its word, and ask nothing
|
||||||
|
// (issue 1399). Reading the plan from the bucket puts it into the view at once, with no wait to bound.
|
||||||
|
heardOf := func(id string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := inv.PlanByID(ctx, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// No holder on record: the merge starts its walk, as before.
|
// No holder on record: the merge starts its walk, as before.
|
||||||
p := merge("c1aaaaaaaa", "modules/app/index.ts")
|
p := merge("c1aaaaaaaa", "modules/app/index.ts")
|
||||||
if p.Waiting() || len(*asked) != 1 {
|
if p.Waiting() || len(*asked) != 1 {
|
||||||
@@ -90,6 +102,7 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
|
|||||||
if err := deliveryCommand(ctx, []string{"go", p.ID}); err == nil {
|
if err := deliveryCommand(ctx, []string{"go", p.ID}); err == nil {
|
||||||
t.Fatal("a walk was let go twice")
|
t.Fatal("a walk was let go twice")
|
||||||
}
|
}
|
||||||
|
heardOf(p.ID)
|
||||||
advanceHeld(ctx, open)
|
advanceHeld(ctx, open)
|
||||||
if len(*asked) != 2 {
|
if len(*asked) != 2 {
|
||||||
t.Fatalf("the word did not start the walk: %v", *asked)
|
t.Fatalf("the word did not start the walk: %v", *asked)
|
||||||
@@ -119,6 +132,7 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
|
|||||||
if err := plansCommand(ctx, []string{"go", p.ID, "--why", "mesh-delivery is down"}); err != nil {
|
if err := plansCommand(ctx, []string{"go", p.ID, "--why", "mesh-delivery is down"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
heardOf(p.ID)
|
||||||
advanceHeld(ctx, open)
|
advanceHeld(ctx, open)
|
||||||
got, _ = inv.PlanByID(ctx, p.ID)
|
got, _ = inv.PlanByID(ctx, p.ID)
|
||||||
if got.Waiting() || !strings.HasPrefix(got.Delivery.By, "a person") || len(*asked) < 4 {
|
if got.Waiting() || !strings.HasPrefix(got.Delivery.By, "a person") || len(*asked) < 4 {
|
||||||
|
|||||||
@@ -138,8 +138,6 @@ func TestTheDeliveryVerbsComposeTheirCommands(t *testing.T) {
|
|||||||
{"delivery-order", map[string]any{"members": "[]"}, []string{"delivery", "order", "--members", "[]"}},
|
{"delivery-order", map[string]any{"members": "[]"}, []string{"delivery", "order", "--members", "[]"}},
|
||||||
{"delivery-check", map[string]any{"group": "feat/x", "members": "[]"},
|
{"delivery-check", map[string]any{"group": "feat/x", "members": "[]"},
|
||||||
[]string{"delivery", "check", "--group", "feat/x", "--members", "[]"}},
|
[]string{"delivery", "check", "--group", "feat/x", "--members", "[]"}},
|
||||||
{"delivery-plan", map[string]any{"repository": "novox/a", "paths": "x", "head": "c0"},
|
|
||||||
[]string{"delivery", "plan", "--repository", "novox/a", "--paths", "x", "--head", "c0"}},
|
|
||||||
{"plans", map[string]any{"go": "plan-1", "why": "down"}, []string{"plans", "go", "plan-1", "--why", "down"}},
|
{"plans", map[string]any{"go": "plan-1", "why": "down"}, []string{"plans", "go", "plan-1", "--why", "down"}},
|
||||||
} {
|
} {
|
||||||
got, err := argvFor(c.verb, c.args)
|
got, err := argvFor(c.verb, c.args)
|
||||||
|
|||||||
@@ -35,16 +35,6 @@ func TestAMergeOfABaseTakesWhatStandsOnItAlong(t *testing.T) {
|
|||||||
t.Fatalf("%s was rebuilt and stands on nothing that moved (%v)", n, names)
|
t.Fatalf("%s was rebuilt and stands on nothing that moved (%v)", n, names)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The dependents come in base order when the merge orders them: the runtime, then shop, then
|
|
||||||
// the plugin that stands on shop.
|
|
||||||
ordered := orderByBases(append([]inventory.Entry{entry("mesh-tools")}, got...), against)
|
|
||||||
pos := map[string]int{}
|
|
||||||
for i, e := range ordered {
|
|
||||||
pos[e.Manifest.Module] = i
|
|
||||||
}
|
|
||||||
if !(pos["mesh-tools"] < pos["shop"] && pos["shop"] < pos["shop-plugin"]) {
|
|
||||||
t.Fatalf("not in base order: %v", ordered)
|
|
||||||
}
|
|
||||||
// Nothing moved: nothing follows.
|
// Nothing moved: nothing follows.
|
||||||
if more := dependentsOf(nil, entries, against); len(more) != 0 {
|
if more := dependentsOf(nil, entries, against); len(more) != 0 {
|
||||||
t.Fatalf("with nothing moved, %d module(s) were rebuilt", len(more))
|
t.Fatalf("with nothing moved, %d module(s) were rebuilt", len(more))
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"github.com/novox/mesh-controller/internal/identity"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// aForgeAndABuildSeat gives a test what a real ask of a build needs: the forge holding the git seat, assigned to anchor, so
|
|
||||||
// the clone URL composes; the identity store; the build seat's queue on a real bus; and the environment of the serving controller, which is
|
|
||||||
// never the controller's terminal (a verb's, in effect: startedAtTheTerminal is false).
|
|
||||||
func aForgeAndABuildSeat(t *testing.T, open *stores) *broker.JetStream {
|
|
||||||
t.Helper()
|
|
||||||
js, _ := aBuildQueue(t)
|
|
||||||
// The ask signs what it sends with the controller's identity, as the serving controller does.
|
|
||||||
identity.ForTest(t)
|
|
||||||
forge, err := catalogue.ParseManifest([]byte(`{"module":"gitea","version":"1",
|
|
||||||
"claims":[{"name":"git","scope":"mesh"}],"provides":[{"name":"git","scope":"mesh"}],
|
|
||||||
"serves":{"git":{"scheme":"http","port":3000}}}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
register(t, open, forge)
|
|
||||||
if _, err := assign(t.Context(), open, "anchor", "gitea"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Setenv(servedVar, "1")
|
|
||||||
return js
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1146/AC9, through the real ask (askABuild, buildOneAsked, verbMayAsk) and a real bus: the first build of a module a
|
|
||||||
// merge adds on the operator's Approve is queued on the build seat; the same ask without that warrant, and the warrant's
|
|
||||||
// ask for a path the merge does not hold, are refused by the guard of a build through a verb.
|
|
||||||
func TestTheApprovedFirstBuildPassesTheGuardOfAVerbAndIsQueued(t *testing.T) {
|
|
||||||
const head, commit = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "0177ea7a00000000"
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
js := aForgeAndABuildSeat(t, open)
|
|
||||||
|
|
||||||
a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head})
|
|
||||||
wasKept, wasAsk := keptDeliveryMerge, routerAskOf
|
|
||||||
keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) {
|
|
||||||
return a.merge, true, nil
|
|
||||||
}
|
|
||||||
routerAskOf = func(context.Context, catalogueReader, string, string) (*asks.Ask, *asks.Warrant, error) {
|
|
||||||
return &a.ask, &a.warrant, nil
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk })
|
|
||||||
|
|
||||||
// Without the warrant the guard refuses, whatever the repository holds.
|
|
||||||
_, err := askABuild(ctx, buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "", "main")
|
|
||||||
var policy *heldAtTheTerminal
|
|
||||||
if !errors.As(err, &policy) {
|
|
||||||
t.Fatalf("1146/AC9: a first build asked without the warrant was not refused: %v", err)
|
|
||||||
}
|
|
||||||
// And a path the merge does not hold is refused on the warrant.
|
|
||||||
warranted := withFirstBuildWarrant(ctx, "novox/mesh-delivery", []string{""}, "the Approve")
|
|
||||||
if _, err := askABuild(warranted, buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "other", "main"); !errors.As(err, &policy) {
|
|
||||||
t.Fatalf("1146/AC9: the warrant covered a path the merge does not hold: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: commit, Number: 7,
|
|
||||||
MergedAt: time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano), Paths: []string{"module.json", "main.go"},
|
|
||||||
ModuleDirs: []string{"."}, ModuleDirsSaid: true}
|
|
||||||
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
queue, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(queue.Asks) != 1 {
|
|
||||||
t.Fatalf("1146/AC9: the build queue holds %+v, want the one first build", queue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// 1146/AC9: the warrant the controller checked covers exactly the repository on the git seat and the module paths the
|
|
||||||
// merge holds; nothing else passes verbMayAsk on it.
|
|
||||||
func TestTheFirstBuildWarrantCoversOnlyItsRepositoryAndItsModulePaths(t *testing.T) {
|
|
||||||
ctx := withFirstBuildWarrant(t.Context(), "novox/mesh-delivery", []string{"", "view"}, "the Approve")
|
|
||||||
w := firstBuildWarrantIn(ctx)
|
|
||||||
for _, c := range []struct {
|
|
||||||
source buildSource
|
|
||||||
path string
|
|
||||||
covers bool
|
|
||||||
}{
|
|
||||||
{buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "", true},
|
|
||||||
{buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "/view/", true},
|
|
||||||
{buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "other", false},
|
|
||||||
{buildSource{Repository: "agent/mesh-delivery", Seat: "git"}, "", false},
|
|
||||||
{buildSource{Repository: "novox/mesh-delivery"}, "", false},
|
|
||||||
{buildSource{Repository: "novox/mesh-delivery", Seat: "elsewhere"}, "", false},
|
|
||||||
} {
|
|
||||||
if got := w.covers(c.source, c.path); got != c.covers {
|
|
||||||
t.Errorf("1146/AC9: %v at %q covered = %v, want %v", c.source, c.path, got, c.covers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if firstBuildWarrantIn(t.Context()).covers(buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "") {
|
|
||||||
t.Error("1146/AC9: a context with no warrant covers an ask")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ func (f following) firstBuilds(ctx context.Context, m link.SourceMoved, entries
|
|||||||
case len(added) == 0:
|
case len(added) == 0:
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if err := askFirstBuilds(ctx, inv, m, now, added, why); err != nil {
|
if err := askFirstBuilds(ctx, f.open, m, now, added, why); err != nil {
|
||||||
return true, notNow(err)
|
return true, notNow(err)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -67,27 +68,35 @@ func keepFirstMerge(ctx context.Context, inv *inventory.Inventory, m link.Source
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// askFirstBuilds keeps the merge, asks the builds, and marks the merge as no longer waiting for them.
|
// askFirstBuilds keeps the merge as answered and registers its modules from the builder's done keys (novox/hq ADR 0501
|
||||||
func askFirstBuilds(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, now time.Time, added []string,
|
// §3, §6): nothing asks their first builds. The merge kept is the registration's warrant (approvedMove reads it by the
|
||||||
|
// commit the indexer's trunk value names); a module whose key is not done yet is registered when the key's write wakes
|
||||||
|
// the take-in (new_modules.go).
|
||||||
|
func askFirstBuilds(ctx context.Context, open *stores, m link.SourceMoved, now time.Time, added []string,
|
||||||
why string) error {
|
why string) error {
|
||||||
|
inv := open.inventory
|
||||||
repository := m.Owner + "/" + m.Repo
|
repository := m.Owner + "/" + m.Repo
|
||||||
if err := keepFirstMerge(ctx, inv, m, now, false); err != nil {
|
if err := keepFirstMerge(ctx, inv, m, now, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s merged into %s (%.8s) on the operator's Approve (%s): the mesh builds nothing from it yet, so the "+
|
fmt.Printf("%s merged into %s (%.8s) on the operator's Approve (%s): the mesh builds nothing from it yet, so %s "+
|
||||||
"first build of %s is asked\n", repository, m.Base, m.Commit, why, strings.Join(added, ", "))
|
"are registered from the builder's done keys, asking no build\n", repository, m.Base, m.Commit, why,
|
||||||
// The ask carries the warrant just checked, for this repository and these module paths alone: the guard of a build
|
strings.Join(added, ", "))
|
||||||
// through a verb (verbMayAsk) lets exactly that through.
|
// Answered: it no longer waits, so the word's arrival again does nothing twice.
|
||||||
paths := make([]string, 0, len(added))
|
if err := inv.AnswerMerge(ctx, repository, m.Commit, inventory.WaitingRecord, false); err != nil {
|
||||||
for _, dir := range added {
|
return err
|
||||||
if dir == "." {
|
|
||||||
dir = ""
|
|
||||||
}
|
|
||||||
paths = append(paths, dir)
|
|
||||||
}
|
}
|
||||||
askBuilds(withFirstBuildWarrant(ctx, repository, paths, why), inv, m, buildSource{Repository: repository, Seat: gitSeat}, added)
|
modules, err := newModulesOf(ctx, repository)
|
||||||
// Answered: it no longer waits, so the word's arrival again asks nothing twice.
|
switch {
|
||||||
return inv.AnswerMerge(ctx, repository, m.Commit, inventory.WaitingRecord, false)
|
case err != nil:
|
||||||
|
// Said, not failed: the merge is answered, and the look at every module when a controller starts, or the next
|
||||||
|
// write of the builder or the indexer for one of them, registers them.
|
||||||
|
fmt.Printf(" the indexer's modules of %s cannot be read now (%v): they are registered when the builder's next "+
|
||||||
|
"write of one wakes the take-in\n", repository, err)
|
||||||
|
case len(modules) > 0:
|
||||||
|
inBackground(func() { takeNewModules(context.WithoutCancel(ctx), open, modules...) })
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resumeFirstBuilds runs firstBuilds for the first merges waiting for the word mesh-delivery just announced for a pull
|
// resumeFirstBuilds runs firstBuilds for the first merges waiting for the word mesh-delivery just announced for a pull
|
||||||
@@ -102,7 +111,7 @@ func (f following) resumeFirstBuilds(ctx context.Context, d deliveryMerge) error
|
|||||||
}
|
}
|
||||||
for _, w := range waiting {
|
for _, w := range waiting {
|
||||||
k := announcedOf(w)
|
k := announcedOf(w)
|
||||||
if !k.FirstBuild || !strings.EqualFold(k.Owner+"/"+k.Repo, d.Owner+"/"+d.Repo) || k.Number != d.Number ||
|
if !k.FirstBuild || len(k.Held) > 0 || !strings.EqualFold(k.Owner+"/"+k.Repo, d.Owner+"/"+d.Repo) || k.Number != d.Number ||
|
||||||
k.HeadSHA != d.HeadSHA {
|
k.HeadSHA != d.HeadSHA {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -125,7 +134,7 @@ func (f following) resumeFirstBuilds(ctx context.Context, d deliveryMerge) error
|
|||||||
case err != nil:
|
case err != nil:
|
||||||
return notNow(err)
|
return notNow(err)
|
||||||
case len(added) > 0:
|
case len(added) > 0:
|
||||||
if err := askFirstBuilds(ctx, inv, k.SourceMoved, time.Now().UTC(), added, why); err != nil {
|
if err := askFirstBuilds(ctx, f.open, k.SourceMoved, time.Now().UTC(), added, why); err != nil {
|
||||||
return notNow(err)
|
return notNow(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,6 +181,29 @@ func firstBuildsOf(ctx context.Context, inv catalogueReader, m link.SourceMoved,
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// heldFirstBuild says a first build was refused because its manifest uses a word the running build-agent does not read,
|
||||||
|
// and its merge is kept waiting for a build-agent that reads it (novox/hq ADR 0482 §5, issue 1173). Read from a merge kept
|
||||||
|
// before R4e, never written since: nothing asks a first build (ADR 0501 §6), and a controller that starts answers such a
|
||||||
|
// merge and registers its modules from their keys (answerHeldMerges).
|
||||||
|
type heldFirstBuild struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Word string `json:"word"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknownWord is how the strict reading of a manifest says a word is not one it knows: encoding/json's own words, which
|
||||||
|
// the build-agent's answer carries whole. The build's answer has no typed field for it (Failed is the builder's words).
|
||||||
|
var unknownWord = regexp.MustCompile(`json: unknown field "([^"]+)"`)
|
||||||
|
|
||||||
|
// refusedWord is the word a build's failure says the reader did not know; empty when it says none.
|
||||||
|
func refusedWord(failed string) string {
|
||||||
|
if m := unknownWord.FindStringSubmatch(failed); m != nil {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// probeFirstBuildsID is the probe that reads the first merges waiting for mesh-delivery's word.
|
// probeFirstBuildsID is the probe that reads the first merges waiting for mesh-delivery's word.
|
||||||
const probeFirstBuildsID = "D18"
|
const probeFirstBuildsID = "D18"
|
||||||
|
|
||||||
@@ -203,9 +235,10 @@ func firstBuildObservations(waiting []inventory.BatchedMerge, now time.Time) []c
|
|||||||
Headline: fmt.Sprintf("The first build of %s is not asked: no Approve was announced", repository),
|
Headline: fmt.Sprintf("The first build of %s is not asked: no Approve was announced", repository),
|
||||||
Explanation: fmt.Sprintf("The merge %.8s into %s adds modules the mesh builds nothing from yet. It registers them "+
|
Explanation: fmt.Sprintf("The merge %.8s into %s adds modules the mesh builds nothing from yet. It registers them "+
|
||||||
"only when mesh-delivery announces that it merged the pull request on your Approve of that head, and no such "+
|
"only when mesh-delivery announces that it merged the pull request on your Approve of that head, and no such "+
|
||||||
"announcement has come in %s. The merge is kept, and the builds are asked the moment the announcement is kept.",
|
"announcement has come in %s. The merge is kept, and the modules are registered from the builder's keys the moment "+
|
||||||
|
"the announcement is kept.",
|
||||||
k.Commit, repository, for_),
|
k.Commit, repository, for_),
|
||||||
Resolved: "mesh-delivery announces the merge, or a hand `build` registers the modules",
|
Resolved: "mesh-delivery announces the merge, or the operator registers the modules at the controller's terminal",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -217,5 +250,28 @@ func probeFirstBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return firstBuildObservations(waiting, time.Now().UTC()), nil
|
now := time.Now().UTC()
|
||||||
|
return append(firstBuildObservations(waiting, now), newModuleObservations(now)...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerHeldMerges answers every merge a controller of before held for a build-agent that reads its new modules'
|
||||||
|
// manifests (issue 1173): nothing asks their first builds any more, and the look at every module when this controller
|
||||||
|
// starts registers them from their keys (ADR 0501 §6, §7).
|
||||||
|
func answerHeldMerges(ctx context.Context, inv *inventory.Inventory) {
|
||||||
|
waiting, err := inv.WaitingMerges(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("the merges held for a newer build-agent could not be read: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, w := range waiting {
|
||||||
|
if k := announcedOf(w); len(k.Held) > 0 {
|
||||||
|
if err := inv.AnswerMerge(ctx, w.Repository, w.Commit, inventory.WaitingRecord, false); err != nil {
|
||||||
|
fmt.Printf("the merge %.8s of %s held for a newer build-agent could not be answered: %v\n", w.Commit,
|
||||||
|
w.Repository, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("the merge %.8s of %s was held for a newer build-agent; its modules are registered from the "+
|
||||||
|
"builder's keys instead\n", w.Commit, w.Repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,71 +5,48 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"slices"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go/jetstream"
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
"github.com/novox/mesh-controller/internal/testbus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 1146/AC7, whole against a real bus and the store, in both orders of the two events (novox/hq ADR 0432, issue 1146):
|
// 1146/AC7 on keys (ADR 0501 §3, §6; issue 1851, A5), whole against a real bus and the store, in both orders of the two
|
||||||
// mesh-delivery #7 merged on the operator's Approve into a repository the mesh builds nothing from. The forge's merge,
|
// events: mesh-delivery #7 merged on the operator's Approve into a repository the mesh builds nothing from. The forge's
|
||||||
// heard as the controller hears it (SourceMoved), and mesh-delivery's word, published on the subject the controller
|
// merge, heard as the controller hears it (SourceMoved), and mesh-delivery's word, published on the subject the
|
||||||
// consumes with the router's record of the ask in its bucket, come in either order; each way the first build of the
|
// controller consumes with the router's record of the ask in its bucket, come in either order; each way the module the
|
||||||
// module the merge commit holds is asked exactly once, its outcome registers the module, and it is in the catalogue.
|
// merge commit holds is registered from the builder's done key of it, and nothing asks a build. Without the Approve in
|
||||||
// Without the Approve in the record nothing is asked. Store-backed: skips without one.
|
// the record it is not registered. Store-backed: skips without one.
|
||||||
func TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegistersTheModule(t *testing.T) {
|
func TestAMergeOnTheApproveIntoANewRepositoryRegistersItsModuleFromItsKey(t *testing.T) {
|
||||||
const head, commit = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "0177ea7a00000000"
|
const head, commit = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "0177ea7a00000000"
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
what string
|
what string
|
||||||
wordFirst bool
|
wordFirst bool
|
||||||
approved bool // the router's record holds the operator's Approve of the merged head
|
approved bool // the router's record holds the operator's Approve of the merged head
|
||||||
wantRegiter bool
|
wantRegister bool
|
||||||
}{
|
}{
|
||||||
{what: "the forge's merge before mesh-delivery's word", approved: true, wantRegiter: true},
|
{what: "the forge's merge before mesh-delivery's word", approved: true, wantRegister: true},
|
||||||
{what: "mesh-delivery's word before the forge's merge", wordFirst: true, approved: true, wantRegiter: true},
|
{what: "mesh-delivery's word before the forge's merge", wordFirst: true, approved: true, wantRegister: true},
|
||||||
{what: "a word whose record holds no Approve", wordFirst: true},
|
{what: "a word whose record holds no Approve", wordFirst: true},
|
||||||
} {
|
} {
|
||||||
t.Run(c.what, func(t *testing.T) {
|
t.Run(c.what, func(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
// The real ask, guard and queue included: only its answers are kept, so the build's outcome can be matched.
|
noBuildAsked(t)
|
||||||
aForgeAndABuildSeat(t, open)
|
b := aBuilderOnTheBus(t)
|
||||||
// Written by the controller's merge path on its own goroutine, read by the test: under one lock.
|
js := b.js
|
||||||
var mu sync.Mutex
|
|
||||||
var asks [][3]string
|
|
||||||
var ids []string
|
|
||||||
asked := func() [][3]string { mu.Lock(); defer mu.Unlock(); return slices.Clone(asks) }
|
|
||||||
realAsk := askABuild
|
|
||||||
askABuild = func(c context.Context, source buildSource, path, ref string) (string, error) {
|
|
||||||
id, err := realAsk(c, source, path, ref)
|
|
||||||
if err == nil {
|
|
||||||
mu.Lock()
|
|
||||||
asks = append(asks, [3]string{source.Repository, path, ref})
|
|
||||||
ids = append(ids, id)
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { askABuild = realAsk })
|
|
||||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "operator-channel", Version: "1",
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "operator-channel", Version: "1",
|
||||||
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.AsksSeat, Records: []string{"asks"}}}},
|
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.AsksSeat, Records: []string{"asks"}}}},
|
||||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/operator-channel", BuiltFrom: "c0ffee"}); err != nil {
|
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/operator-channel", BuiltFrom: "c0ffee"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
js, err := broker.Dial(testbus.URL(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(js.Close)
|
|
||||||
if err := broker.AssertMeshStreams(js); err != nil {
|
if err := broker.AssertMeshStreams(js); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -87,10 +64,14 @@ func TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegis
|
|||||||
a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head})
|
a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head})
|
||||||
if c.approved {
|
if c.approved {
|
||||||
record, _ := json.Marshal(map[string]any{"state": "closed", "ask": a.ask, "warrant": a.warrant})
|
record, _ := json.Marshal(map[string]any{"state": "closed", "ask": a.ask, "warrant": a.warrant})
|
||||||
if _, err := records.Put(ctx, theDelivery+"."+a.merge.AskID, record); err != nil {
|
if _, err := records.Put(ctx, sdkdelivery.TellerModule+"."+a.merge.AskID, record); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The indexer's value and the builder's done key of the module the merge commit holds.
|
||||||
|
fp := fpOf('9')
|
||||||
|
manifest := b.indexed(t, "mesh-delivery-view", "1", "novox/mesh-delivery", ".", commit, fp)
|
||||||
|
b.built(t, "mesh-delivery-view", "1", fp, manifest, sdkdelivery.BuildDone)
|
||||||
|
|
||||||
following := following{open: open}
|
following := following{open: open}
|
||||||
server := link.ConnectNats(js, nil, noListener{})
|
server := link.ConnectNats(js, nil, noListener{})
|
||||||
@@ -104,8 +85,6 @@ func TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegis
|
|||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() { defer close(done); _ = server.Serve(serving) }()
|
go func() { defer close(done); _ = server.Serve(serving) }()
|
||||||
t.Cleanup(func() { stop(); <-done })
|
t.Cleanup(func() { stop(); <-done })
|
||||||
servingBus.Store(js)
|
|
||||||
t.Cleanup(func() { servingBus.Store(nil) })
|
|
||||||
|
|
||||||
word := func() {
|
word := func() {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -133,37 +112,27 @@ func TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegis
|
|||||||
forge()
|
forge()
|
||||||
} else {
|
} else {
|
||||||
forge()
|
forge()
|
||||||
if len(asked()) != 0 {
|
time.Sleep(300 * time.Millisecond)
|
||||||
t.Fatalf("1146/AC7: the merge asked %v before mesh-delivery's word was kept", asked())
|
if _, known := inventoryHolds(t, inv, "mesh-delivery-view"); known {
|
||||||
|
t.Fatal("1146/AC7: registered before mesh-delivery's word was kept")
|
||||||
}
|
}
|
||||||
word()
|
word()
|
||||||
}
|
}
|
||||||
if !c.wantRegiter {
|
if !c.wantRegister {
|
||||||
if len(asked()) != 0 {
|
time.Sleep(time.Second)
|
||||||
t.Fatalf("1146/AC7: a merge whose record holds no Approve asked %v", asked())
|
if _, known := inventoryHolds(t, inv, "mesh-delivery-view"); known {
|
||||||
|
t.Fatal("1146/AC7: a merge whose record holds no Approve registered its module")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
want := [3]string{"novox/mesh-delivery", "", "main"}
|
becomes(t, 10*time.Second, "the module registered", func() bool {
|
||||||
becomes(t, 10*time.Second, "the first build asked", func() bool { return len(asked()) >= 1 })
|
_, known := inventoryHolds(t, inv, "mesh-delivery-view")
|
||||||
if got := asked(); len(got) != 1 || got[0] != want {
|
return known
|
||||||
t.Fatalf("1146/AC7: the merge asked %v, want %v once", got, want)
|
})
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
first := ids[0]
|
|
||||||
mu.Unlock()
|
|
||||||
build := onTrunk(first, "novox/mesh-delivery", "git", "", map[string]any{"module": "mesh-delivery-view", "version": "1"})
|
|
||||||
build.Commit = commit
|
|
||||||
if _, _, err := takeIn(ctx, inv, build); err != nil {
|
|
||||||
t.Fatalf("1146/AC7: the outcome of the first build was refused: %v", err)
|
|
||||||
}
|
|
||||||
src, err := inv.SourceOf(ctx, "mesh-delivery-view")
|
src, err := inv.SourceOf(ctx, "mesh-delivery-view")
|
||||||
if err != nil || src.Repository != "novox/mesh-delivery" {
|
if err != nil || src.Repository != "novox/mesh-delivery" {
|
||||||
t.Fatalf("1146/AC7: the module is built from %q (%v)", src.Repository, err)
|
t.Fatalf("1146/AC7: the module is built from %q (%v)", src.Repository, err)
|
||||||
}
|
}
|
||||||
if _, known := inventoryHolds(t, inv, "mesh-delivery-view"); !known {
|
|
||||||
t.Fatal("1146/AC7: the module is not in the catalogue")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func registerFrom(t *testing.T, forms *sourceForms) (int64, error) {
|
|||||||
mesh := &aMeshRegisteredFromTheURL{anEmptyMesh{asked: true,
|
mesh := &aMeshRegisteredFromTheURL{anEmptyMesh{asked: true,
|
||||||
request: inventory.BuildRequest{ID: "build-1", Repository: aRepoOnTheForge, For: "build"}}, aRepoOnTheForge}
|
request: inventory.BuildRequest{ID: "build-1", Repository: aRepoOnTheForge, For: "build"}}, aRepoOnTheForge}
|
||||||
return mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", inventory.Source{Repository: aRepoOnTheForge},
|
return mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", inventory.Source{Repository: aRepoOnTheForge},
|
||||||
"build-1", "", "main")
|
byBuild("build-1"), "", "main")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1277/AC2: an address unread twice, then read, is waited out and the forge is asked.
|
// 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"
|
const elsewhere = "http://elsewhere.example/x/y.git"
|
||||||
mesh := &aMeshRegisteredFromTheURL{anEmptyMesh{asked: true, request: inventory.BuildRequest{ID: "build-1", For: "build"}}, elsewhere}
|
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",
|
_, err := mayRegisterIn(t.Context(), mesh, sourceFormsRead(context.Background(), time.Now, read), "mesh-delivery-view",
|
||||||
inventory.Source{Repository: elsewhere}, "build-1", "", "main")
|
inventory.Source{Repository: elsewhere}, byBuild("build-1"), "", "main")
|
||||||
if !errors.Is(err, errNotItsSource) || !strings.Contains(err.Error(), "is not on the mesh's forge") {
|
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)
|
t.Fatalf("1277/AC4: the refusal changed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func TestTheForgeNotAnsweringInTimeIsNotAskedAgain(t *testing.T) {
|
|||||||
// An ask that hangs past the bound is cut off at the bound, not at its own wait, and the failure still says it
|
// An ask that hangs past the bound is cut off at the bound, not at its own wait, and the failure still says it
|
||||||
// waited: the receive loop is held no longer than the bound (novox/hq issue 486).
|
// waited: the receive loop is held no longer than the bound (novox/hq issue 486).
|
||||||
func TestAnAskHangingPastTheBoundIsCutOffAtIt(t *testing.T) {
|
func TestAnAskHangingPastTheBoundIsCutOffAtIt(t *testing.T) {
|
||||||
w := waitOut{bound: 200 * time.Millisecond, first: 10 * time.Millisecond, most: 50 * time.Millisecond,
|
w := waitOut{bound: 100 * time.Millisecond, first: 10 * time.Millisecond, most: 50 * time.Millisecond,
|
||||||
now: time.Now, sleep: sleepOrDone}
|
now: time.Now, sleep: sleepOrDone}
|
||||||
asked := 0
|
asked := 0
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A move held for a person is asked of the person (novox/hq issue 1732, issue 1320; ADR 0377, ADR 0259).
|
||||||
|
//
|
||||||
|
// **A wait only a person can end is said to that person at once.** ADR 0377 holds a recorded module's move that
|
||||||
|
// recreates a container or touches its data until a person pushes the machine. The walk said so in its note and
|
||||||
|
// ended "done", `status` named the machine behind, and nobody was asked: on 2026-10-21 mailu's new build waited on
|
||||||
|
// novox for 40 minutes behind a plan that read "done, 3 tier(s)", and on 2026-10-19 mssql waited on ace the same way.
|
||||||
|
// Row S24 raises one condition per machine that has such a move, for the operator, with the answer **Release**: the
|
||||||
|
// push of that machine, which the controller's own `push` verb performs on the operator's warrant (ADR 0259). It ends
|
||||||
|
// when the machine no longer waits, whoever pushed it.
|
||||||
|
//
|
||||||
|
// A hold a person chose is not asked again — a module they set to `record` with a why, and the bus, whose step has
|
||||||
|
// its own row (S17).
|
||||||
|
|
||||||
|
// kindMovesHeld is S24's kind: machine.<node>.moves-held.
|
||||||
|
const kindMovesHeld = "moves-held"
|
||||||
|
|
||||||
|
// heldFacts is one machine and the moves of recorded modules that wait there for a person.
|
||||||
|
type heldFacts struct {
|
||||||
|
node string
|
||||||
|
moves []heldMove
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatherHeldMoves is, per machine that holds at least one move no person chose to hold, all its held moves, by machine.
|
||||||
|
func gatherHeldMoves(ctx context.Context, inv *inventory.Inventory) ([]heldFacts, error) {
|
||||||
|
nodes, err := inv.Nodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the machines cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
f, err := readMoveFacts(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []heldFacts
|
||||||
|
for _, n := range nodes {
|
||||||
|
moves, err := heldMovesOn(ctx, inv, f, n.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h := heldFacts{node: n.Name, moves: moves}
|
||||||
|
if len(h.asked()) > 0 {
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// asked are the moves no person chose to hold, and chosen the ones a person did (a module set to record with a why, the bus).
|
||||||
|
func (h heldFacts) asked() []heldMove { return h.byChoice(false) }
|
||||||
|
func (h heldFacts) chosen() []heldMove { return h.byChoice(true) }
|
||||||
|
|
||||||
|
func (h heldFacts) byChoice(chosen bool) []heldMove {
|
||||||
|
var out []heldMove
|
||||||
|
for _, m := range h.moves {
|
||||||
|
if m.Chosen == chosen {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// modules are the held moves' modules, in order, of all of them.
|
||||||
|
func (h heldFacts) modules() []string { return moduleNames(h.moves) }
|
||||||
|
|
||||||
|
func moduleNames(moves []heldMove) []string {
|
||||||
|
out := make([]string, 0, len(moves))
|
||||||
|
for _, m := range moves {
|
||||||
|
out = append(out, m.Module)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseAction is the one answer a held move offers: the push of the machine, approved by the operator. The push
|
||||||
|
// names every module whose move waits there as one that may move its data: the operator's Approve is the word
|
||||||
|
// ADR 0217 asks for, and the ask says so. Offered only where no person's own hold is on the machine too: a push
|
||||||
|
// composes every recorded module at its current build, so it would send that move as well, which the ask never named
|
||||||
|
// (ADR 0259 §6), and hold the whole machine if that module's data would move.
|
||||||
|
func (h heldFacts) releaseAction() conditions.Action {
|
||||||
|
return conditions.Action{Label: "Release", Verb: "mesh-controller.push", Level: conditions.LevelApprove,
|
||||||
|
Arguments: map[string]string{"node": h.node, "move": strings.Join(moduleNames(h.asked()), ","), "why": "",
|
||||||
|
"cause": conditions.CauseOperatorAnswer}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldWords are the plain words of a machine's held moves: which modules wait, why in general, and what an Approve does.
|
||||||
|
func heldWords(h heldFacts) (headline, explanation, needs string, resolved string) {
|
||||||
|
names := namesWords(moduleNames(h.asked()), 3)
|
||||||
|
headline = clipWords(conditions.Capital(names)+" waits for you on "+h.node, conditions.HeadlineMax)
|
||||||
|
recreates, data := false, false
|
||||||
|
for _, m := range h.asked() {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(m.Why, "recreates"):
|
||||||
|
recreates = true
|
||||||
|
default:
|
||||||
|
data = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
why := "would change what it runs"
|
||||||
|
switch {
|
||||||
|
case recreates && data:
|
||||||
|
why = "would restart a container or touch its data"
|
||||||
|
case recreates:
|
||||||
|
why = "would restart one of its containers"
|
||||||
|
case data:
|
||||||
|
why = "would touch its files or data"
|
||||||
|
}
|
||||||
|
explanation = clipWords(fmt.Sprintf("The new version of %s %s on %s, so it does not go out with its delivery; "+
|
||||||
|
"it waits for you. Release sends %s everything it is owed, these among it, and lets their data move where it "+
|
||||||
|
"has to. Nothing is sent until you answer.", names, why, h.node, h.node), conditions.ExplanationMax)
|
||||||
|
needs = "release them " + FromMeshMCPServer
|
||||||
|
if chosen := h.chosen(); len(chosen) > 0 {
|
||||||
|
// A push of the machine would send the move a person chose to hold as well: no answer is offered here.
|
||||||
|
explanation = clipWords(fmt.Sprintf("The new version of %s %s on %s, so it does not go out with its delivery; "+
|
||||||
|
"it waits for you. %s is also held there by your own choice, and a push of %s would send that too, so "+
|
||||||
|
"nothing is offered here: the push is yours at the controller's terminal.",
|
||||||
|
names, why, h.node, conditions.Capital(namesWords(moduleNames(chosen), 3)), h.node), conditions.ExplanationMax)
|
||||||
|
needs = fmt.Sprintf("push %s at the controller's terminal, once you have decided about %s.", h.node,
|
||||||
|
namesWords(moduleNames(chosen), 3))
|
||||||
|
}
|
||||||
|
resolved = "Resolved: " + names + " is no longer waiting on " + h.node
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchMovesHeld is S24: a machine that has moves of recorded modules waiting for a person, said to the operator with the
|
||||||
|
// answer that sends them. The condition ends when the machine no longer waits: it is no longer among the facts.
|
||||||
|
func watchMovesHeld(f *signalFacts) []conditions.Observation {
|
||||||
|
var out []conditions.Observation
|
||||||
|
for _, h := range f.held {
|
||||||
|
var said []string
|
||||||
|
for _, m := range h.moves {
|
||||||
|
said = append(said, fmt.Sprintf("%s %s → %s %s", m.Module, short(m.From), short(m.To), m.Why))
|
||||||
|
}
|
||||||
|
headline, explanation, needs, resolved := heldWords(h)
|
||||||
|
if c := h.chosen(); len(c) > 0 {
|
||||||
|
said = append(said, "held by a person's own choice: "+strings.Join(moduleNames(c), ", "))
|
||||||
|
}
|
||||||
|
var actions []conditions.Action
|
||||||
|
if len(h.chosen()) == 0 {
|
||||||
|
actions = []conditions.Action{h.releaseAction()}
|
||||||
|
}
|
||||||
|
out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: h.node, Kind: kindMovesHeld,
|
||||||
|
Machine: h.node, Severity: conditions.Warning, Resolver: conditions.ResolverOperator,
|
||||||
|
Summary: fmt.Sprintf("%s has %d move(s) of recorded modules waiting for a person's push: %s — `push %s` sends them",
|
||||||
|
h.node, len(h.moves), strings.Join(h.modules(), ", "), h.node),
|
||||||
|
Said: strings.Join(said, "; "),
|
||||||
|
Headline: headline, Explanation: explanation, Needs: needs, Resolved: resolved,
|
||||||
|
Actions: actions})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// planHeld is, for a plan that ended or is running, the moves its modules carried that a machine still holds for a
|
||||||
|
// person: "module on machine", sorted. The plan's own record says the move waited when it was judged
|
||||||
|
// (PlanModule.Recorded); the machines say whether it still does, and for the build this plan carried, so a plan older
|
||||||
|
// than the build now held is not named.
|
||||||
|
func planHeld(p inventory.Plan, held []heldFacts) []string {
|
||||||
|
var out []string
|
||||||
|
for _, h := range held {
|
||||||
|
for _, m := range h.moves {
|
||||||
|
s := p.Modules[m.Module]
|
||||||
|
if s == nil || !strings.HasPrefix(s.Recorded[h.node], "waits:") || !sameCommit(s.Commit, m.To) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, m.Module+" on "+h.node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldSaid is what a plan's line adds when it carries a move that a machine holds for a person (novox/hq issue 1732): a
|
||||||
|
// plan whose module waits for a push is not plainly "done".
|
||||||
|
func heldSaid(p inventory.Plan, held []heldFacts) string {
|
||||||
|
waiting := planHeld(p, held)
|
||||||
|
if len(waiting) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(" — %d held for a person: %s (the operator is asked)", len(waiting), strings.Join(waiting, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldNow reads the machines' held moves for a plan's line, nil when they cannot be read: the line then reads as before.
|
||||||
|
func heldNow(ctx context.Context, inv *inventory.Inventory) []heldFacts {
|
||||||
|
held, err := gatherHeldMoves(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
return nil // empty-on-error: a plan's line then reads as it did before; the watchdog (S24) says it cannot read
|
||||||
|
}
|
||||||
|
return held
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func inventoryUpgrade(why string) inventory.Upgrade { return inventory.Upgrade{Why: why, By: "jochen"} }
|
||||||
|
|
||||||
|
func mustMoveFacts(t *testing.T, rm *recordedMesh) moveFacts {
|
||||||
|
t.Helper()
|
||||||
|
f, err := readMoveFacts(t.Context(), rm.open.inventory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC8 (integration): a mesh running mailu at c1 on two machines, whose registered c2 recreates a container, is read
|
||||||
|
// as two machines each holding mailu's move for a person; a bundle-only c3 on top changes nothing of it; after a machine
|
||||||
|
// is sent c2 (as a push records it) it is no longer among them, and plans that carried c2 say nothing more of it.
|
||||||
|
func TestMachinesHoldingAMoveForAPersonAreReadFromTheRecords(t *testing.T) {
|
||||||
|
rm := aRecordedMesh(t, aMailu(true))
|
||||||
|
c2 := aMailu(true)
|
||||||
|
resourceOf(&c2, "imap")["image"] = "registry.invalid/imap@sha256:2"
|
||||||
|
rm.register(t, c2, "c2", time.Now().Add(-time.Minute))
|
||||||
|
ctx := t.Context()
|
||||||
|
held, err := gatherHeldMoves(ctx, rm.open.inventory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(held) != 2 || held[0].node != "anchor" || held[1].node != "laptop" || len(held[0].moves) != 1 ||
|
||||||
|
held[0].moves[0].Module != "mailu" || held[0].moves[0].To != "c2" || !strings.Contains(held[0].moves[0].Why, "recreates") {
|
||||||
|
t.Fatalf("held %+v", held)
|
||||||
|
}
|
||||||
|
if err := rm.open.inventory.RecordSent(ctx, nodeID(t, rm.open, "anchor"), "d-anchor-c2", map[string]string{"mailu": "c2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held, err = gatherHeldMoves(ctx, rm.open.inventory)
|
||||||
|
if err != nil || len(held) != 1 || held[0].node != "laptop" {
|
||||||
|
t.Fatalf("after anchor was sent c2: %+v (%v)", held, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC9 (integration): a module a person set to record is no ask: they chose the hold.
|
||||||
|
func TestAHoldAPersonChoseIsNotAsked(t *testing.T) {
|
||||||
|
rm := aRecordedMesh(t, aMailu(false))
|
||||||
|
c2 := aMailu(false)
|
||||||
|
c2.Bundles[0].Digest = "sha256:bbb"
|
||||||
|
rm.register(t, c2, "c2", time.Now().Add(-time.Minute))
|
||||||
|
if err := rm.open.inventory.SetUpgradeOf(t.Context(), "mailu", inventoryUpgrade("the migration week")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held, err := gatherHeldMoves(t.Context(), rm.open.inventory)
|
||||||
|
if err != nil || len(held) != 0 {
|
||||||
|
t.Fatalf("a machine holding only a person's own hold is asked: %+v (%v)", held, err)
|
||||||
|
}
|
||||||
|
if moves, _ := heldMovesOn(t.Context(), rm.open.inventory, mustMoveFacts(t, rm), "anchor"); len(moves) != 1 || !moves[0].Chosen {
|
||||||
|
t.Fatalf("status still names it, marked as chosen: %+v", moves)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1742/AC4 (integration): a registered build naming an image of the store that the store lacks is read as a missing
|
||||||
|
// image on each machine that runs the module; one the store holds is not; and what the store holds is not asked twice.
|
||||||
|
func TestARegisteredBuildNamingAnImageTheStoreLacksIsRead(t *testing.T) {
|
||||||
|
m := aMailu(true)
|
||||||
|
resourceOf(&m, "imap")["image"] = "artifact-store://mailu/imap@sha256:" + strings.Repeat("e", 64)
|
||||||
|
rm := aRecordedMesh(t, m)
|
||||||
|
asked := 0
|
||||||
|
lacking := func(_ context.Context, reference string) (bool, error) { asked++; return false, nil }
|
||||||
|
imagesHeld.Lock()
|
||||||
|
imagesHeld.held = map[string]bool{}
|
||||||
|
imagesHeld.Unlock()
|
||||||
|
got, err := gatherMissingImages(t.Context(), rm.open.inventory, lacking)
|
||||||
|
if err != nil || len(got) != 2 || got[0].Module != "mailu" || got[0].Node != "anchor" || got[1].Node != "laptop" {
|
||||||
|
t.Fatalf("missing %+v (%v)", got, err)
|
||||||
|
}
|
||||||
|
holding := func(_ context.Context, reference string) (bool, error) { asked++; return true, nil }
|
||||||
|
if got, err := gatherMissingImages(t.Context(), rm.open.inventory, holding); err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("an image the store holds is missing: %+v (%v)", got, err)
|
||||||
|
}
|
||||||
|
before := asked
|
||||||
|
if _, err := gatherMissingImages(t.Context(), rm.open.inventory, lacking); err != nil || asked != before {
|
||||||
|
t.Fatalf("an image the store was found to hold was asked about again (%d, %v)", asked-before, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A move held for a person is asked of the person** (novox/hq issue 1732, issue 1320; ADR 0377, ADR 0259).
|
||||||
|
|
||||||
|
// aHeldMailu is novox holding mailu's new build because it would recreate a container: the walk's own words.
|
||||||
|
func aHeldMailu() heldFacts {
|
||||||
|
return heldFacts{node: "novox", moves: []heldMove{{Module: "mailu", From: "b2f5ebb2aaaa", To: "3259765dbbbb",
|
||||||
|
Why: "waits: recreates 1 of mailu's 12 container(s) (with a new image: automx)"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func heldConditionOn(f *signalFacts) conditions.Condition {
|
||||||
|
o := watchMovesHeld(f)[0]
|
||||||
|
return conditions.Condition{Key: o.Key(), Kind: o.Kind, Severity: o.Severity, Headline: o.Headline,
|
||||||
|
Explanation: conditions.Verdict(o.Needs, o.Explanation), Needs: o.Needs, Actions: o.Actions}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC1: a machine holding a move of a recorded module for a person raises one condition for the operator, with
|
||||||
|
// Release as its only answer: the machine's push, approved, naming the modules whose moves wait there.
|
||||||
|
func TestAHeldMoveIsRaisedWithReleaseAsItsOnlyAction(t *testing.T) {
|
||||||
|
f := calm(time.Date(2026, 10, 21, 17, 30, 0, 0, time.UTC))
|
||||||
|
f.held = []heldFacts{aHeldMailu()}
|
||||||
|
got := watchMovesHeld(f)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
o := got[0]
|
||||||
|
if o.Kind != "moves-held" || o.Scope != conditions.ScopeMachine || o.ID != "novox" || o.Severity != conditions.Warning ||
|
||||||
|
o.Resolver != conditions.ResolverOperator || o.Key() != "machine.novox.moves-held" {
|
||||||
|
t.Fatalf("kind, scope, subject, severity or resolver: %+v (%s)", o, o.Key())
|
||||||
|
}
|
||||||
|
if len(o.Actions) != 1 {
|
||||||
|
t.Fatalf("actions: %+v", o.Actions)
|
||||||
|
}
|
||||||
|
a := o.Actions[0]
|
||||||
|
if a.Label != "Release" || a.Verb != "mesh-controller.push" || a.Level != conditions.LevelApprove ||
|
||||||
|
a.Arguments["node"] != "novox" || a.Arguments["move"] != "mailu" || a.Arguments["cause"] != conditions.CauseOperatorAnswer {
|
||||||
|
t.Fatalf("the action is not the push of novox: %+v", a)
|
||||||
|
}
|
||||||
|
if _, takes := a.Arguments["why"]; !takes {
|
||||||
|
t.Fatal("the push takes a why, which the asker fills in with the operator's answer")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"mailu", "novox"} {
|
||||||
|
if !strings.Contains(o.Headline+o.Explanation, want) {
|
||||||
|
t.Errorf("the words do not name %q: %q / %q", want, o.Headline, o.Explanation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(o.Said, "recreates 1 of mailu's 12") {
|
||||||
|
t.Errorf("the evidence does not carry the walk's own words: %q", o.Said)
|
||||||
|
}
|
||||||
|
if w := (conditions.Observation{Headline: o.Headline, Explanation: o.Explanation, Needs: o.Needs, Resolved: o.Resolved}); w.Headline == "" || len(o.Headline) > conditions.HeadlineMax {
|
||||||
|
t.Errorf("headline: %q", o.Headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC2: a machine with nothing held raises nothing, so the condition ends the look after the move is sent.
|
||||||
|
func TestAMachineHoldingNothingRaisesNoHeldMoveCondition(t *testing.T) {
|
||||||
|
f := calm(time.Date(2026, 10, 21, 17, 30, 0, 0, time.UTC))
|
||||||
|
if got := watchMovesHeld(f); len(got) != 0 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC3: the condition is asked on a channel at the approve level in words that say what Release does, and the
|
||||||
|
// operator's warrant calls the controller's own push with the machine and the modules, once.
|
||||||
|
func TestAHeldMovesReleaseIsAskedAndAWarrantPushesTheMachine(t *testing.T) {
|
||||||
|
f := calm(time.Date(2026, 10, 21, 17, 30, 0, 0, time.UTC))
|
||||||
|
f.held = []heldFacts{aHeldMailu()}
|
||||||
|
c := heldConditionOn(f)
|
||||||
|
r := newAskerRig(t)
|
||||||
|
r.open = []conditions.Condition{c}
|
||||||
|
if err := r.a.reconcile(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sent := r.asksSent(t)
|
||||||
|
if len(sent) != 1 || len(sent[0].Options) != 1 {
|
||||||
|
t.Fatalf("asked %+v", sent)
|
||||||
|
}
|
||||||
|
o := sent[0].Options[0]
|
||||||
|
if o.Label != "Release" || o.Level != asks.Approve || o.Does != "the held builds are sent to novox" {
|
||||||
|
t.Errorf("the option is %+v", o)
|
||||||
|
}
|
||||||
|
if err := sent[0].Check(r.now); err != nil {
|
||||||
|
t.Errorf("the ask is refused as made: %v", err)
|
||||||
|
}
|
||||||
|
w := r.warrantFor(t, c.Key, "Release")
|
||||||
|
answerWith(t, r, w)
|
||||||
|
answerWith(t, r, w)
|
||||||
|
if len(r.called) != 1 || !strings.HasPrefix(r.called[0], "mesh-controller.push@ ") ||
|
||||||
|
!strings.Contains(r.called[0], `"node":"novox"`) || !strings.Contains(r.called[0], `"move":"mailu"`) ||
|
||||||
|
!strings.Contains(r.called[0], `"cause":"`+conditions.CauseOperatorAnswer+`"`) ||
|
||||||
|
!strings.Contains(r.called[0], "chose Release") {
|
||||||
|
t.Fatalf("called %v", r.called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC4: the controller may call its own push and upgrade verbs on a warrant: the grant names them.
|
||||||
|
func TestTheControllerMayActOnAWarrantForPushAndUpgrade(t *testing.T) {
|
||||||
|
for _, verb := range []string{"push", "upgrade"} {
|
||||||
|
granted := false
|
||||||
|
for _, v := range broker.VerbsTheControllerActsOnAWarrant {
|
||||||
|
granted = granted || (v.Seat == broker.ControllerSeat && v.Verb == verb)
|
||||||
|
}
|
||||||
|
if !granted {
|
||||||
|
t.Errorf("the controller may not act on a warrant for %s", verb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aDoneMailuPlan() inventory.Plan {
|
||||||
|
return inventory.Plan{ID: "plan-1792585970446304628", Repository: "novox/mesh-catalog", State: inventory.PlanDone,
|
||||||
|
Tiers: [][]string{{"a"}, {"b"}, {"mailu"}},
|
||||||
|
Modules: map[string]*inventory.PlanModule{"mailu": {State: "built", Commit: "3259765dbbbbcccc",
|
||||||
|
Recorded: map[string]string{"novox": "waits: recreates 1 of mailu's 12 container(s)"}}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC5: a done plan that carries a move a machine still holds for a person says so on its line; once the machine
|
||||||
|
// no longer holds it, or holds another build of the module, the line says nothing more.
|
||||||
|
func TestADonePlanSaysWhatItHoldsForAPerson(t *testing.T) {
|
||||||
|
p := aDoneMailuPlan()
|
||||||
|
line := planLine(p, time.Date(2026, 10, 21, 16, 0, 0, 0, time.UTC)) + heldSaid(p, []heldFacts{aHeldMailu()})
|
||||||
|
if !strings.Contains(line, "done, 3 tier(s)") || !strings.Contains(line, "1 held for a person: mailu on novox") {
|
||||||
|
t.Errorf("the line: %q", line)
|
||||||
|
}
|
||||||
|
if said := heldSaid(p, nil); said != "" {
|
||||||
|
t.Errorf("a plan whose moves were sent says %q", said)
|
||||||
|
}
|
||||||
|
other := aHeldMailu()
|
||||||
|
other.moves[0].To = "9999999ffff"
|
||||||
|
if said := heldSaid(p, []heldFacts{other}); said != "" {
|
||||||
|
t.Errorf("a plan older than the build now held says %q", said)
|
||||||
|
}
|
||||||
|
p.Modules["mailu"].Recorded = map[string]string{"novox": recordedRollsWhy}
|
||||||
|
if said := heldSaid(p, []heldFacts{aHeldMailu()}); said != "" {
|
||||||
|
t.Errorf("a move the plan sent says %q", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC6: the builds held after a failed release are asked of the operator: the held-release condition offers
|
||||||
|
// Release, which calls the controller's own `upgrade` with release-backlog and nothing else, once, on a warrant.
|
||||||
|
func TestTheBuildsHeldAfterAFailedReleaseAreAskedAndReleasedOnAWarrant(t *testing.T) {
|
||||||
|
saved := backlogNow
|
||||||
|
t.Cleanup(func() { backlogNow = saved })
|
||||||
|
backlogNow.held = "release-1792595552499982311 failed (failed on novox); what waits is released again by a person"
|
||||||
|
backlogNow.waiting = map[string][]inventory.CarriedMove{"novox": {{Module: "indexer"}, {Module: "nextcloud"}}}
|
||||||
|
got := backlogObservation()
|
||||||
|
if len(got) != 1 || len(got[0].Actions) != 1 {
|
||||||
|
t.Fatalf("raised %+v", got)
|
||||||
|
}
|
||||||
|
a := got[0].Actions[0]
|
||||||
|
if a.Label != "Release" || a.Verb != "mesh-controller.upgrade" || a.Level != conditions.LevelApprove ||
|
||||||
|
a.Arguments["release-backlog"] != "true" || a.Arguments["cause"] != conditions.CauseOperatorAnswer {
|
||||||
|
t.Fatalf("the action: %+v", a)
|
||||||
|
}
|
||||||
|
o := got[0]
|
||||||
|
c := conditions.Condition{Key: o.Key(), Kind: o.Kind, Severity: o.Severity, Headline: o.Headline,
|
||||||
|
Explanation: conditions.Verdict(o.Needs, o.Explanation), Needs: o.Needs, Actions: o.Actions}
|
||||||
|
r := newAskerRig(t)
|
||||||
|
r.open = []conditions.Condition{c}
|
||||||
|
if err := r.a.reconcile(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sent := r.asksSent(t)
|
||||||
|
if len(sent) != 1 || len(sent[0].Options) != 1 || sent[0].Options[0].Does != "the builds held after the failed delivery are released" {
|
||||||
|
t.Fatalf("asked %+v", sent)
|
||||||
|
}
|
||||||
|
w := r.warrantFor(t, c.Key, "Release")
|
||||||
|
answerWith(t, r, w)
|
||||||
|
answerWith(t, r, w)
|
||||||
|
if len(r.called) != 1 || !strings.HasPrefix(r.called[0], "mesh-controller.upgrade@ ") ||
|
||||||
|
!strings.Contains(r.called[0], `"release-backlog":"true"`) || !strings.Contains(r.called[0], "chose Release") {
|
||||||
|
t.Fatalf("called %v", r.called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC7: a plan whose send waits for a gate while the builds are held after a failed release reads as waiting for
|
||||||
|
// a person to release them, not "building ... LATE"; with nothing held it still reads as the wait it is.
|
||||||
|
func TestAPlanWaitingForAGateBehindAFailedReleaseIsNotLate(t *testing.T) {
|
||||||
|
saved := backlogNow
|
||||||
|
t.Cleanup(func() { backlogNow = saved })
|
||||||
|
now := time.Date(2026, 10, 21, 18, 50, 0, 0, time.UTC)
|
||||||
|
p := inventory.Plan{ID: "plan-1", Repository: "novox/mesh-sdk", Commit: "c0ffee00", State: inventory.PlanBuilding,
|
||||||
|
Tiers: [][]string{{"indexer"}}, TierEntered: now.Add(-100 * time.Minute), Created: now.Add(-110 * time.Minute),
|
||||||
|
Modules: map[string]*inventory.PlanModule{"indexer": {State: "built"}},
|
||||||
|
Note: refusalNote(0, errors.New("a build waits there for its gate: novox"))}
|
||||||
|
backlogNow.held = ""
|
||||||
|
if line := planLine(p, now); !strings.Contains(line, "a build waits there for its gate: novox") {
|
||||||
|
t.Errorf("the wait is not said: %q", line)
|
||||||
|
}
|
||||||
|
backlogNow.held = "release-1 failed (failed on novox); what waits is released again by a person"
|
||||||
|
line := planLine(p, now)
|
||||||
|
if strings.Contains(line, "LATE") || strings.Contains(line, "building") ||
|
||||||
|
!strings.Contains(line, "until a person releases it") {
|
||||||
|
t.Errorf("a plan held behind a failed release reads %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1732/AC10: a machine that also holds a move a person chose to hold gets no Release, since a push would send that move
|
||||||
|
// too; the condition names both and says the push is the controller's terminal. A machine with only asked moves keeps it.
|
||||||
|
func TestAMachineAlsoHoldingAPersonsOwnHoldIsOfferedNoRelease(t *testing.T) {
|
||||||
|
f := calm(time.Date(2026, 10, 21, 17, 30, 0, 0, time.UTC))
|
||||||
|
h := aHeldMailu()
|
||||||
|
h.moves = append(h.moves, heldMove{Module: "nftables", From: "n1aaaa", To: "n2bbbb",
|
||||||
|
Why: "waits: a person set it to record, and holds every move of it until they push", Chosen: true})
|
||||||
|
f.held = []heldFacts{h}
|
||||||
|
got := watchMovesHeld(f)
|
||||||
|
if len(got) != 1 || len(got[0].Actions) != 0 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
o := got[0]
|
||||||
|
text := o.Headline + o.Explanation + o.Needs + o.Summary + o.Said
|
||||||
|
for _, want := range []string{"mailu", "nftables", "terminal", "push novox"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("does not say %q: %q", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs}
|
||||||
|
if why, ok := conditions.PlainWords(w, "novox"); !ok {
|
||||||
|
t.Errorf("not plain: %s", why)
|
||||||
|
}
|
||||||
|
f.held = []heldFacts{aHeldMailu()}
|
||||||
|
if got := watchMovesHeld(f); len(got) != 1 || len(got[0].Actions) != 1 || got[0].Actions[0].Arguments["move"] != "mailu" {
|
||||||
|
t.Fatalf("a machine with only asked moves: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/artifacts"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// No declaration is sent that names an image the artifact store does not hold (novox/hq issue 1742).
|
||||||
|
//
|
||||||
|
// On 2026-10-21 at 17:13 a send to novox named `mailu/automx@sha256:c62150b8…`; the build record keeps that digest
|
||||||
|
// ("recent-build") and the store did not hold it. The node-engine removed the running container, then could not pull
|
||||||
|
// the replacement, and every later apply on novox failed for the next hour while the mail clients' autoconfiguration
|
||||||
|
// had no backend. The node-engine's half (pull first, replace after) is its own change; this is the controller's: **a
|
||||||
|
// module whose image the store lacks is left out of the declaration**, as a module whose setting nobody gave is
|
||||||
|
// (ADR 0163, rule 6) — the node-engine keeps what it has of it and touches none of its containers — and **it is said**,
|
||||||
|
// row S25: a condition naming the module, the machine and the image, until the store holds the image again (a new build
|
||||||
|
// of the module, or the copy put back).
|
||||||
|
//
|
||||||
|
// Judged only for an image the store's own address names, from the send's own resources; an image of another registry
|
||||||
|
// is no business of the store's. A store that cannot be asked judges nothing: a send is not refused for a question
|
||||||
|
// that did not answer.
|
||||||
|
|
||||||
|
// kindImageMissing is S25's kind: module.<module>.<machine>.image-missing.
|
||||||
|
const kindImageMissing = "image-missing"
|
||||||
|
|
||||||
|
// missingImage is a module held out of a machine's declaration because the store lacks an image it names.
|
||||||
|
type missingImage struct {
|
||||||
|
Node, Module, Image string
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeHolds is whether the store holds the manifest an image reference at its address names.
|
||||||
|
type storeHolds func(ctx context.Context, reference string) (bool, error)
|
||||||
|
|
||||||
|
// imageInStore is the artifact-store reference of an image the store's address names, "" for any other.
|
||||||
|
func imageInStore(image, address string) string {
|
||||||
|
if address == "" || !strings.HasPrefix(image, address+"/") || !strings.Contains(image, "@sha256:") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
reference := catalogue.Recorded(image)
|
||||||
|
if _, ours := catalogue.InArtifactStore(reference); !ours {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdBackMissingImages leaves out of the declaration every module that names an image the store does not hold,
|
||||||
|
// and answers what it left out. The module's resources go, it joins LeftOut with the reason, and its build stays the
|
||||||
|
// one the machine was last sent (`was`).
|
||||||
|
func holdBackMissingImages(ctx context.Context, node string, s *sendable, address string, holds storeHolds,
|
||||||
|
was map[string]string) []missingImage {
|
||||||
|
lacks := map[string]bool{} // reference → the store does not hold it; absent: not asked, or no answer
|
||||||
|
asked := map[string]bool{}
|
||||||
|
lacking := map[string]string{} // module → the image
|
||||||
|
for _, r := range s.Resources {
|
||||||
|
if fmt.Sprint(r["type"]) != "container" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
image, _ := r["image"].(string)
|
||||||
|
reference := imageInStore(image, address)
|
||||||
|
module := s.moduleOf[fmt.Sprint(r["id"])]
|
||||||
|
if reference == "" || module == "" || lacking[module] != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !asked[reference] {
|
||||||
|
asked[reference] = true
|
||||||
|
held, err := holds(ctx, reference)
|
||||||
|
// empty-on-error: the store did not answer, so nothing is held back for a question unanswered
|
||||||
|
lacks[reference] = err == nil && !held
|
||||||
|
}
|
||||||
|
if lacks[reference] {
|
||||||
|
lacking[module] = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lacking) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
kept := s.Resources[:0:0]
|
||||||
|
for _, r := range s.Resources {
|
||||||
|
if _, out := lacking[s.moduleOf[fmt.Sprint(r["id"])]]; !out {
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Resources = kept
|
||||||
|
if s.leftOutWhy == nil {
|
||||||
|
s.leftOutWhy = map[string]string{}
|
||||||
|
}
|
||||||
|
var found []missingImage
|
||||||
|
for module, image := range lacking {
|
||||||
|
s.leftOutWhy[module] = "its image " + image + " is not in the artifact store"
|
||||||
|
if !slices.Contains(s.LeftOut, module) {
|
||||||
|
s.LeftOut = append(s.LeftOut, module)
|
||||||
|
}
|
||||||
|
if s.Builds != nil {
|
||||||
|
if prior, sent := was[module]; sent {
|
||||||
|
s.Builds[module] = prior
|
||||||
|
} else {
|
||||||
|
delete(s.Builds, module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found = append(found, missingImage{Node: node, Module: module, Image: image})
|
||||||
|
}
|
||||||
|
sort.Strings(s.LeftOut)
|
||||||
|
sort.Slice(found, func(i, j int) bool { return found[i].Module < found[j].Module })
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutMissingImages is composeSend's last step: the store asked about the images the declaration names, the modules
|
||||||
|
// whose image it lacks left out and kept in the ledger S25 reads. A store that cannot be found or asked leaves the
|
||||||
|
// declaration as composed.
|
||||||
|
func withoutMissingImages(ctx context.Context, open *stores, node string, s sendable) sendable {
|
||||||
|
inv := open.inventory
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
address, err := artifactStoreAddress(ctx, inv, shelf, node)
|
||||||
|
if err != nil || address == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
sent, known, err := inv.SentBuilds(ctx, node)
|
||||||
|
if err != nil || !known {
|
||||||
|
sent = nil
|
||||||
|
}
|
||||||
|
store := artifacts.Store{Address: address}
|
||||||
|
found := holdBackMissingImages(ctx, node, &s, address, store.HoldsManifest, sent)
|
||||||
|
for _, m := range found {
|
||||||
|
fmt.Printf("%s: %s is left out of the declaration: its image %s is not in the artifact store\n", node, m.Module, m.Image)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageFacts are the images S25 raises: a module whose registered build names an image the store lacks, on each machine
|
||||||
|
// that runs it.
|
||||||
|
type imageFacts struct {
|
||||||
|
missingImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagesHeld remembers the images the store was found to hold: a stored image is named by its digest and stays what it
|
||||||
|
// is, so it is asked about once; one it lacks is asked again at every look.
|
||||||
|
var imagesHeld = struct {
|
||||||
|
sync.Mutex
|
||||||
|
held map[string]bool
|
||||||
|
}{held: map[string]bool{}}
|
||||||
|
|
||||||
|
// gatherMissingImages is every machine's module whose registered build — the one a send would carry — names an image of
|
||||||
|
// the artifact store that the store does not hold. Read from the catalogue and the store, never from what a send did:
|
||||||
|
// a push is a command of its own, and the serving controller's watchdog hears nothing of it.
|
||||||
|
func gatherMissingImages(ctx context.Context, inv *inventory.Inventory, holds storeHolds) ([]imageFacts, error) {
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the catalogue cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
var out []imageFacts
|
||||||
|
for _, module := range sortedKeysOf(modulesOf(shelf)) {
|
||||||
|
for _, r := range shelf[module].Resources {
|
||||||
|
if fmt.Sprint(r["type"]) != "container" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
image, _ := r["image"].(string)
|
||||||
|
if _, ours := catalogue.InArtifactStore(image); !ours || !strings.Contains(image, "@sha256:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imagesHeld.Lock()
|
||||||
|
known := imagesHeld.held[image]
|
||||||
|
imagesHeld.Unlock()
|
||||||
|
if !known {
|
||||||
|
held, err := holds(ctx, image)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the artifact store cannot be asked about %s: %w", image, err)
|
||||||
|
}
|
||||||
|
if held {
|
||||||
|
imagesHeld.Lock()
|
||||||
|
imagesHeld.held[image] = true
|
||||||
|
imagesHeld.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
running, err := inv.Running(ctx, module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, node := range running {
|
||||||
|
out = append(out, imageFacts{missingImage{Node: node, Module: module, Image: image}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modulesOf is the shelf's modules as a map of names, for sortedKeysOf.
|
||||||
|
func modulesOf(shelf map[string]catalogue.Manifest) map[string]string {
|
||||||
|
out := make(map[string]string, len(shelf))
|
||||||
|
for m := range shelf {
|
||||||
|
out[m] = m
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchMissingImages is S25: a module left out of a machine's declaration because the store lacks its image, said at
|
||||||
|
// once. The condition ends when the store holds the image, or a send names another.
|
||||||
|
func watchMissingImages(f *signalFacts) []conditions.Observation {
|
||||||
|
var out []conditions.Observation
|
||||||
|
for _, m := range f.images {
|
||||||
|
out = append(out, conditions.Observation{Scope: conditions.ScopeModule, ID: m.Module + "." + m.Node, Kind: kindImageMissing,
|
||||||
|
Machine: m.Node, Severity: conditions.Warning, Resolver: conditions.ResolverOperator,
|
||||||
|
Summary: fmt.Sprintf("%s is left out of every send to %s: its declaration names an image that the artifact store does "+
|
||||||
|
"not hold (the evidence names it); the running container is untouched", m.Module, m.Node),
|
||||||
|
Said: "image " + m.Image + " is not in the store",
|
||||||
|
Headline: clipWords(conditions.Capital(m.Module)+" is not updated on "+m.Node, conditions.HeadlineMax),
|
||||||
|
Explanation: clipWords(fmt.Sprintf("The new version of %s names a container image that the mesh's store does not "+
|
||||||
|
"hold, so it was not sent to %s. What runs there is untouched, and the rest of %s is sent as usual.",
|
||||||
|
m.Module, m.Node, m.Node), conditions.ExplanationMax),
|
||||||
|
Needs: fmt.Sprintf("build %s again; the details name the image.", m.Module),
|
||||||
|
Resolved: "Resolved: the store holds the image of " + m.Module + " again"})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatherMissingImagesHere is gatherMissingImages asking the mesh's own artifact store; nothing to ask where the mesh has
|
||||||
|
// none on its network yet.
|
||||||
|
func gatherMissingImagesHere(ctx context.Context, inv *inventory.Inventory) ([]imageFacts, error) {
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the catalogue cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
address, err := artifactStoreAddress(ctx, inv, shelf, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if address == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return gatherMissingImages(ctx, inv, artifacts.Store{Address: address}.HoldsManifest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **No declaration is sent that names an image the artifact store does not hold** (novox/hq issue 1742).
|
||||||
|
|
||||||
|
const storeAt = "novox.internal:5100"
|
||||||
|
|
||||||
|
// automx is the image the send of 2026-10-21 17:13 named and the store lacked; the others are held.
|
||||||
|
const (
|
||||||
|
automxImage = storeAt + "/mailu/automx@sha256:c62150b8e2cbcc18cb911b098c2877f928761a1e6251667f39ea18251ea35591"
|
||||||
|
imapImage = storeAt + "/mailu/imap@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
)
|
||||||
|
|
||||||
|
func aMailSend() sendable {
|
||||||
|
return sendable{
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "mailu.automx", "type": "container", "image": automxImage},
|
||||||
|
{"id": "mailu.automx-config", "type": "file", "path": "/etc/automx.conf"},
|
||||||
|
{"id": "mailu.imap", "type": "container", "image": imapImage},
|
||||||
|
{"id": "other.web", "type": "container", "image": "docker.io/library/nginx@sha256:" + strings.Repeat("b", 64)},
|
||||||
|
{"id": "dns.dnsmasq", "type": "container", "image": storeAt + "/dnsmasq/main@sha256:" + strings.Repeat("c", 64)},
|
||||||
|
},
|
||||||
|
moduleOf: map[string]string{"mailu.automx": "automx", "mailu.automx-config": "automx", "mailu.imap": "mailu",
|
||||||
|
"other.web": "other", "dns.dnsmasq": "dnsmasq"},
|
||||||
|
Builds: map[string]string{"automx": "b2f5ebb2new", "mailu": "m2", "other": "o1", "dnsmasq": "d1"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeHolding(missing ...string) (storeHolds, *[]string) {
|
||||||
|
var asked []string
|
||||||
|
return func(_ context.Context, reference string) (bool, error) {
|
||||||
|
asked = append(asked, reference)
|
||||||
|
for _, m := range missing {
|
||||||
|
if strings.Contains(reference, m) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}, &asked
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1742/AC1: given a send naming automx's image, which the store does not hold, when the declaration is judged, then
|
||||||
|
// every resource of automx is out of it, automx is among the modules left out with the reason, its build is the one the
|
||||||
|
// machine was last sent, and every other module — an image of another registry among them — is as composed.
|
||||||
|
func TestAModuleWhoseImageTheStoreLacksIsLeftOutOfTheSend(t *testing.T) {
|
||||||
|
s := aMailSend()
|
||||||
|
holds, asked := storeHolding("mailu/automx@")
|
||||||
|
found := holdBackMissingImages(t.Context(), "novox", &s, storeAt, holds, map[string]string{"automx": "b2f5ebb2old"})
|
||||||
|
if len(found) != 1 || found[0].Module != "automx" || found[0].Node != "novox" || found[0].Image != automxImage {
|
||||||
|
t.Fatalf("found %+v", found)
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for _, r := range s.Resources {
|
||||||
|
ids = append(ids, r["id"].(string))
|
||||||
|
}
|
||||||
|
if got := strings.Join(ids, ","); got != "mailu.imap,other.web,dns.dnsmasq" {
|
||||||
|
t.Errorf("the declaration keeps %s", got)
|
||||||
|
}
|
||||||
|
if len(s.LeftOut) != 1 || s.LeftOut[0] != "automx" || !strings.Contains(s.leftOutWhy["automx"], "not in the artifact store") {
|
||||||
|
t.Errorf("left out %v because %v", s.LeftOut, s.leftOutWhy)
|
||||||
|
}
|
||||||
|
if s.Builds["automx"] != "b2f5ebb2old" || s.Builds["mailu"] != "m2" || s.Builds["other"] != "o1" {
|
||||||
|
t.Errorf("builds %v", s.Builds)
|
||||||
|
}
|
||||||
|
for _, a := range *asked {
|
||||||
|
if strings.Contains(a, "docker.io") {
|
||||||
|
t.Errorf("the store was asked about an image of another registry: %s", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1742/AC2: given a module never sent to the machine, when its image is missing, then it has no build in the record of the
|
||||||
|
// send; given the store does not answer, then nothing is held back for a question unanswered.
|
||||||
|
func TestAnUnansweredStoreHoldsNothingBackAndANewModuleHasNoBuild(t *testing.T) {
|
||||||
|
s := aMailSend()
|
||||||
|
holds, _ := storeHolding("mailu/automx@")
|
||||||
|
found := holdBackMissingImages(t.Context(), "novox", &s, storeAt, holds, nil)
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("%+v", found)
|
||||||
|
}
|
||||||
|
if _, has := s.Builds["automx"]; has {
|
||||||
|
t.Errorf("a module never sent is recorded as sent: %v", s.Builds)
|
||||||
|
}
|
||||||
|
s = aMailSend()
|
||||||
|
silent := func(context.Context, string) (bool, error) { return false, errors.New("registry: connection refused") }
|
||||||
|
if found := holdBackMissingImages(t.Context(), "novox", &s, storeAt, silent, nil); len(found) != 0 || len(s.LeftOut) != 0 ||
|
||||||
|
len(s.Resources) != 5 {
|
||||||
|
t.Errorf("an unanswered store held back %+v, left out %v", found, s.LeftOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1742/AC3: a module left out for its image raises a condition for the operator naming the module, the machine and the
|
||||||
|
// image in the evidence, plain words, and no answer to give; a mesh whose images are all held raises nothing.
|
||||||
|
func TestAMissingImageIsRaisedForTheOperator(t *testing.T) {
|
||||||
|
f := calm(time.Date(2026, 10, 21, 17, 40, 0, 0, time.UTC))
|
||||||
|
if got := watchMissingImages(f); len(got) != 0 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
f.images = []imageFacts{{missingImage{Node: "novox", Module: "automx", Image: automxImage}}}
|
||||||
|
got := watchMissingImages(f)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
o := got[0]
|
||||||
|
if o.Kind != "image-missing" || o.Key() != "module.automx.novox.image-missing" || o.Resolver != conditions.ResolverOperator ||
|
||||||
|
o.Severity != conditions.Warning || o.Machine != "novox" {
|
||||||
|
t.Fatalf("%+v (%s)", o, o.Key())
|
||||||
|
}
|
||||||
|
if !strings.Contains(o.Said, "c62150b8") || !strings.Contains(o.Headline+o.Explanation, "automx") {
|
||||||
|
t.Errorf("words: %q / %q / %q", o.Headline, o.Explanation, o.Said)
|
||||||
|
}
|
||||||
|
w := conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs}
|
||||||
|
if why, ok := conditions.PlainWords(w, "novox"); !ok {
|
||||||
|
t.Errorf("not plain: %s", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ func TestAMergeSaysTheModulesWhoseBuildSourceItLeftUnchanged(t *testing.T) {
|
|||||||
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
|
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
|
||||||
},
|
},
|
||||||
"route-proxy": {
|
"route-proxy": {
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"go.mod", "examples/route-proxy/"}},
|
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"go.mod", "modules/route-proxy/cmd/route-proxy/"}},
|
||||||
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
"github.com/novox/mesh-controller/internal/artifacts"
|
||||||
|
"github.com/novox/mesh-controller/internal/builderkeys"
|
||||||
|
"github.com/novox/mesh-controller/internal/buildreads"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The take-in from the builder's done key (novox/hq ADR 0501, ADR 0498 §8, ADR 0453 §5; issue 1851).
|
||||||
|
//
|
||||||
|
// From R4e the controller asks no build but the builder's own module's: a module of a merge is taken from the builder's
|
||||||
|
// key for the fingerprint the indexer's trunk value of the module names (ADR 0501 §2). Only a done key whose every
|
||||||
|
// artifact names a reference is taken (ADR 0498 §8); a key not done yet, one waiting for a reference, a templates
|
||||||
|
// archive the store does not answer, or a list of predefined keys the forge does not answer is waited for and said,
|
||||||
|
// never failed and never delivered from. A failed key fails, in the builder's own words. The take-in then records the
|
||||||
|
// key as a build of today's record layout, so a controller of before reads it on a rollback, and judges it as every
|
||||||
|
// build is judged (registerJudged).
|
||||||
|
|
||||||
|
// errKeyWaits says a take-in from a key waits: for the builder, a reference, the store or the forge. Never a failure.
|
||||||
|
var errKeyWaits = errors.New("waits")
|
||||||
|
|
||||||
|
// errKeyFailed says the module's key failed: the builder's own words follow.
|
||||||
|
var errKeyFailed = errors.New("the builder's key failed")
|
||||||
|
|
||||||
|
// keyReads is what a take-in from a key reads of the builder and the indexer (builderkeys.Reader, or a test's).
|
||||||
|
type keyReads interface {
|
||||||
|
Build(ctx context.Context, module, fp string) (builderkeys.Key, error)
|
||||||
|
Manifest(ctx context.Context, digest string) (sdkdelivery.Manifest, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyBuildID is the id a key's build is recorded under: the key and its revision, so the same revision recorded twice
|
||||||
|
// is one record, and a rebuild of the same fingerprint (a new revision, ADR 0429 §18) another.
|
||||||
|
func keyBuildID(k builderkeys.Key) string {
|
||||||
|
part, err := sdkdelivery.FingerprintKeyPart(k.Fingerprint)
|
||||||
|
if err != nil {
|
||||||
|
part = strings.TrimPrefix(k.Fingerprint, sdkdelivery.SourcePrefix2)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("key-%s-%d", part, k.Revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readArchive is the archive a key's reference names, read from the artifact store and checked against its digest. A
|
||||||
|
// variable so a test needs no store.
|
||||||
|
var readArchive = func(ctx context.Context, inv *inventory.Inventory, reference string) ([]byte, 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 on no network the controller reaches")
|
||||||
|
}
|
||||||
|
return artifacts.Store{Address: address}.GetBlob(ctx, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// predefinedHead is the list of predefined keys at the head of the catalogue repository's trunk, and that head.
|
||||||
|
type predefinedHead struct {
|
||||||
|
List catalogue.PredefinedList
|
||||||
|
Repository string
|
||||||
|
Commit string
|
||||||
|
// Generation orders two heads of the trunk: the head's commit time in seconds. A list kept from a build's
|
||||||
|
// generation (its commit count) is always older than a head read here.
|
||||||
|
Generation int
|
||||||
|
}
|
||||||
|
|
||||||
|
// predefinedAtTrunk is the list of predefined keys at the head of the catalogue repository's trunk (ADR 0501 §8), read
|
||||||
|
// from the forge. A variable so a test needs no forge.
|
||||||
|
var predefinedAtTrunk = func(ctx context.Context, inv *inventory.Inventory) (predefinedHead, error) {
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return predefinedHead{}, err
|
||||||
|
}
|
||||||
|
repository := ""
|
||||||
|
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 repository == "" {
|
||||||
|
return predefinedHead{}, fmt.Errorf("no module of the catalogue names the repository %s on the forge",
|
||||||
|
catalogue.CatalogueRepository)
|
||||||
|
}
|
||||||
|
var heads []struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Commit struct {
|
||||||
|
Committer struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
} `json:"committer"`
|
||||||
|
} `json:"commit"`
|
||||||
|
}
|
||||||
|
if err := askGitea(ctx, "gitea_api", map[string]any{"path": "/repos/" + repository +
|
||||||
|
"/commits?limit=1&stat=false&verification=false&files=false"}, &heads); err != nil {
|
||||||
|
return predefinedHead{}, err
|
||||||
|
}
|
||||||
|
if len(heads) == 0 || heads[0].SHA == "" {
|
||||||
|
return predefinedHead{}, fmt.Errorf("the forge named no head of %s's trunk", repository)
|
||||||
|
}
|
||||||
|
head := predefinedHead{Repository: repository, Commit: heads[0].SHA,
|
||||||
|
Generation: int(heads[0].Commit.Committer.Date.Unix())}
|
||||||
|
var file struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Encoding string `json:"encoding"`
|
||||||
|
}
|
||||||
|
if err := askGitea(ctx, "gitea_api", map[string]any{"path": "/repos/" + repository + "/contents/" +
|
||||||
|
catalogue.PredefinedFile + "?ref=" + head.Commit}, &file); err != nil {
|
||||||
|
return predefinedHead{}, err
|
||||||
|
}
|
||||||
|
if file.Encoding != "base64" {
|
||||||
|
return predefinedHead{}, fmt.Errorf("the forge answered %s at %.8s in the encoding %q", catalogue.PredefinedFile,
|
||||||
|
head.Commit, file.Encoding)
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(file.Content)
|
||||||
|
if err != nil {
|
||||||
|
return predefinedHead{}, err
|
||||||
|
}
|
||||||
|
if head.List, err = catalogue.ParsePredefined(raw); err != nil {
|
||||||
|
return predefinedHead{}, fmt.Errorf("%s at %.8s is refused: %w", catalogue.PredefinedFile, head.Commit, err)
|
||||||
|
}
|
||||||
|
return head, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
js, err := aBus()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
raw, _ := json.Marshal(args)
|
||||||
|
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", tool, raw, 30*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if answer.Error != "" {
|
||||||
|
return fmt.Errorf("gitea.%s: %s", tool, answer.Error)
|
||||||
|
}
|
||||||
|
var wrapped struct {
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(answer.Result, &wrapped); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(wrapped.Result, into)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyTake is one take-in from a key: the module, the indexer's trunk value naming the key's fingerprint, and the
|
||||||
|
// registration's warrant.
|
||||||
|
type keyTake struct {
|
||||||
|
Module string
|
||||||
|
Trunk sdkdelivery.Module
|
||||||
|
Ask registrationAsk
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeInKey takes a module in from the builder's key for the fingerprint its trunk value names: records the key as a
|
||||||
|
// build and registers the module, or says why not. errKeyWaits when it waits, errKeyFailed when the key failed.
|
||||||
|
func takeInKey(ctx context.Context, inv *inventory.Inventory, keys keyReads, t keyTake) (
|
||||||
|
catalogue.Manifest, inventory.Build, error) {
|
||||||
|
head, err := predefinedAtTrunk(ctx, inv)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
keepPredefinedHead(ctx, inv, head)
|
||||||
|
previous, err := inv.Builds(ctx, t.Module, 5)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Manifest{}, inventory.Build{}, err
|
||||||
|
}
|
||||||
|
kept, manifest, err := buildOfKey(ctx, keys, t, keyContext{list: head.List, previous: previous,
|
||||||
|
archive: func(ctx context.Context, reference string) ([]byte, error) { return readArchive(ctx, inv, reference) }})
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Manifest{}, kept, err
|
||||||
|
}
|
||||||
|
if err := inv.RecordBuild(ctx, kept); err != nil {
|
||||||
|
return catalogue.Manifest{}, kept, err
|
||||||
|
}
|
||||||
|
recorded := inventory.Source{Repository: t.Trunk.Repository, Seat: gitSeat, Path: keyPath(t.Trunk),
|
||||||
|
BuiltFrom: t.Trunk.Commit, Head: t.Trunk.Commit, Against: kept.Against}
|
||||||
|
trunk := "main"
|
||||||
|
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil {
|
||||||
|
recorded.Ref = followedBranch(was.Ref)
|
||||||
|
if recorded.Ref != "" {
|
||||||
|
trunk = recorded.Ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registered, err := registerJudged(ctx, inv, takenBuild{kept: kept, manifest: manifest, recorded: recorded,
|
||||||
|
ask: t.Ask, path: recorded.Path, trunk: trunk, by: "the builder", what: t.Trunk.Repository,
|
||||||
|
commit: t.Trunk.Commit})
|
||||||
|
return registered, kept, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyContext is what a key's build is made with besides the key: the list of predefined keys its manifest is judged
|
||||||
|
// against, the module's recent builds (the bases its last one stood on are carried) and how an archive is read.
|
||||||
|
type keyContext struct {
|
||||||
|
list catalogue.PredefinedList
|
||||||
|
previous []inventory.Build
|
||||||
|
archive func(ctx context.Context, reference string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyPath is a module's directory as the catalogue records it: empty for a module at its repository's root.
|
||||||
|
func keyPath(m sdkdelivery.Module) string {
|
||||||
|
if m.Path == sdkdelivery.RootPath {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Trim(m.Path, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOfKey reads the key of the trunk value's fingerprint and makes its build record and the manifest it resolves to
|
||||||
|
// (ADR 0498 §8, ADR 0501 §1, §8): the module's manifest by the key's digest, judged against the list of predefined keys
|
||||||
|
// at the catalogue's trunk head, resolved with the key's artifacts.
|
||||||
|
func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) (
|
||||||
|
inventory.Build, catalogue.Manifest, error) {
|
||||||
|
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 == "" {
|
||||||
|
why := t.Trunk.None2
|
||||||
|
if why == "" {
|
||||||
|
why = "the indexer wrote no fingerprint of the new form"
|
||||||
|
}
|
||||||
|
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 err != nil {
|
||||||
|
return waits("%s's key cannot be read: %v", t.Module, err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case k.Failed():
|
||||||
|
return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("%w: %s", errKeyFailed, k.Says())
|
||||||
|
case !k.Done():
|
||||||
|
return waits("%s", k.Says())
|
||||||
|
case len(k.Unreferenced()) > 0:
|
||||||
|
return waits("%s", k.Says())
|
||||||
|
}
|
||||||
|
src, err := keys.Manifest(ctx, k.Build.Manifest)
|
||||||
|
if err != nil {
|
||||||
|
return waits("%s's manifest: %v", t.Module, err)
|
||||||
|
}
|
||||||
|
manifest, err := catalogue.ParseManifestWith([]byte(src.JSON), kc.list)
|
||||||
|
if err != nil {
|
||||||
|
// **A word the running controller does not read waits for the controller that reads it** (ADR 0501 §7): the
|
||||||
|
// key stays done, and the controller that reads the word registers it with no second build.
|
||||||
|
if word := refusedWord(err.Error()); word != "" {
|
||||||
|
return waits("%s's manifest uses the word %q, which this controller does not read: it is registered "+
|
||||||
|
"when a controller that reads it runs", t.Module, word)
|
||||||
|
}
|
||||||
|
return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("the builder built %s and its manifest is "+
|
||||||
|
"refused: %w", t.Module, err)
|
||||||
|
}
|
||||||
|
if manifest.Module != t.Module {
|
||||||
|
return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("the key of %s names the manifest of %s",
|
||||||
|
t.Module, manifest.Module)
|
||||||
|
}
|
||||||
|
built, err := builtOfKey(ctx, kc.archive, manifest, k)
|
||||||
|
if err != nil {
|
||||||
|
return waits("%v", err)
|
||||||
|
}
|
||||||
|
resolved, err := manifest.Resolve(built)
|
||||||
|
if err != nil {
|
||||||
|
return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("the builder built %s and it does not resolve: %w",
|
||||||
|
t.Module, err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return inventory.Build{}, catalogue.Manifest{}, err
|
||||||
|
}
|
||||||
|
kept := inventory.Build{ID: keyBuildID(k), Repository: t.Trunk.Repository, Module: t.Module,
|
||||||
|
Commit: t.Trunk.Commit, On: k.Build.By, Path: keyPath(t.Trunk), Manifest: raw,
|
||||||
|
SourceFingerprint: k.Fingerprint, At: time.Now().UTC()}
|
||||||
|
// **Read from the manifest, fresh at every key** (the same rule build-agent says, internal/buildreads). Sources are
|
||||||
|
// left unsaid but for a module whose every artifact names its paths: the planner then reads the module's whole
|
||||||
|
// directory, over-planning and never under-planning, and a fingerprint that did not change moves nothing (R4e A9).
|
||||||
|
// **Its build source where its every artifact names its paths** (novox/hq ADR 0499 §1, §5; issue 1758, B1): the
|
||||||
|
// planner reads a merge's changed files against those paths and the manifest alone, so a change elsewhere in the
|
||||||
|
// module's directory touches nothing it builds from.
|
||||||
|
if own := pathsBuildSource(manifest, keyPath(t.Trunk)); own != nil {
|
||||||
|
kept.Sources = []inventory.BuildSource{{Paths: own}}
|
||||||
|
}
|
||||||
|
for _, c := range buildreads.Of(manifest) {
|
||||||
|
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: c.Repository, Ref: c.Ref})
|
||||||
|
}
|
||||||
|
// **The bases its last build stood on, carried** (R4e, lead decision): what the collector keeps a mirrored base by.
|
||||||
|
// A key names none; the module's newest successful build's are carried so the keep set does not change.
|
||||||
|
for _, b := range kc.previous {
|
||||||
|
if b.Worked() && b.Module == t.Module && b.ID != kept.ID {
|
||||||
|
kept.Against = append([]string(nil), b.Against...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)})
|
||||||
|
}
|
||||||
|
return kept, resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindOf is an artifact's kind: the key's, else the manifest's.
|
||||||
|
func kindOf(m catalogue.Manifest, a sdkdelivery.BuiltArtifact) string {
|
||||||
|
if a.Kind != "" || m.Build == nil {
|
||||||
|
return a.Kind
|
||||||
|
}
|
||||||
|
for _, d := range m.Build.Artifacts {
|
||||||
|
if d.Name == a.Name {
|
||||||
|
return d.Kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtOfKey is each of a done key's artifacts as Resolve reads it: its reference and digest, a TypeScript bundle's
|
||||||
|
// launchers by the build modules' one rule (mesh-sdk buildstep.Launchers), and, for the archive a module packs its
|
||||||
|
// templates into, each template's text read back from the archive the store holds under the key's reference.
|
||||||
|
func builtOfKey(ctx context.Context, archive func(context.Context, string) ([]byte, error), m catalogue.Manifest,
|
||||||
|
k builderkeys.Key) (
|
||||||
|
[]catalogue.Built, error) {
|
||||||
|
declared := map[string]catalogue.Artifact{}
|
||||||
|
if m.Build != nil {
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
declared[a.Name] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templates := ""
|
||||||
|
if m.Templated() {
|
||||||
|
templates = catalogue.TemplatesArtifact(m)
|
||||||
|
}
|
||||||
|
var out []catalogue.Built
|
||||||
|
for _, a := range k.Build.Artifacts {
|
||||||
|
d := declared[a.Name]
|
||||||
|
b := catalogue.Built{Name: a.Name, Kind: kindOf(m, a), Reference: catalogue.Recorded(a.Reference),
|
||||||
|
Digest: a.Digest}
|
||||||
|
if launchers := buildstep.Launchers(d.Language, d.Entrypoints); len(launchers) > 0 {
|
||||||
|
b.Launchers = launchers
|
||||||
|
}
|
||||||
|
if a.Name == templates && templates != "" {
|
||||||
|
body, err := archive(ctx, b.Reference)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the templates archive %q of %s cannot be read from the artifact store: %v",
|
||||||
|
a.Name, m.Module, err)
|
||||||
|
}
|
||||||
|
if b.Texts, err = unpackTexts(body); err != nil {
|
||||||
|
return nil, fmt.Errorf("the templates archive %q of %s cannot be read back: %v", a.Name, m.Module, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unpackTexts is every file of a packed templates archive (gzip over tar), by its path inside it.
|
||||||
|
func unpackTexts(body []byte) (map[string]string, error) {
|
||||||
|
zipped, err := gzip.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer zipped.Close()
|
||||||
|
reader := tar.NewReader(zipped)
|
||||||
|
texts := map[string]string{}
|
||||||
|
for {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return texts, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if header.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, err := io.ReadAll(io.LimitReader(reader, 16<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
texts[strings.TrimPrefix(header.Name, "./")] = string(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepPredefinedHead keeps the list read at the catalogue's trunk head when it is newer than the one kept, as a build of
|
||||||
|
// the catalogue's did (ADR 0348 §7), and judges every manifest read again against it. Said, never fatal: the list read
|
||||||
|
// is the one this take-in judges against either way.
|
||||||
|
func keepPredefinedHead(ctx context.Context, inv *inventory.Inventory, head predefinedHead) {
|
||||||
|
kept, err := inv.KeepPredefined(ctx, inventory.PredefinedRecord{Keys: head.List, Repository: head.Repository,
|
||||||
|
Commit: head.Commit, Generation: head.Generation, Build: "the trunk head " + short(head.Commit)})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("the list of predefined keys at %s's trunk head %s is not kept: %v\n", head.Repository,
|
||||||
|
short(head.Commit), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kept {
|
||||||
|
fmt.Printf("the list of predefined keys at %s's trunk head %s is published: %s\n", head.Repository,
|
||||||
|
short(head.Commit), strings.Join(head.List.Keys(), ", "))
|
||||||
|
}
|
||||||
|
if err := loadPredefined(ctx, inv); err != nil {
|
||||||
|
fmt.Printf("the list of predefined keys kept cannot be read: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBuilderKeys is the builder's and the indexer's buckets, read on a connection for the moment.
|
||||||
|
func openBuilderKeys() (builderkeys.Reader, func(), error) {
|
||||||
|
js, err := aBus()
|
||||||
|
if err != nil {
|
||||||
|
return builderkeys.Reader{}, nil, err
|
||||||
|
}
|
||||||
|
api, err := jetstream.New(js.Conn())
|
||||||
|
if err != nil {
|
||||||
|
js.Close()
|
||||||
|
return builderkeys.Reader{}, nil, err
|
||||||
|
}
|
||||||
|
return builderkeys.Reader{JS: api}, js.Close, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathsBuildSource is the build source of a module whose every artifact names the paths it is built from (ADR 0499
|
||||||
|
// §1): its manifest and each path, relative to its repository's root, a path read as a file or everything below a
|
||||||
|
// directory of that name (`<path>/**` holds both). Nil when an artifact names none: the module reads its directory.
|
||||||
|
func pathsBuildSource(m catalogue.Manifest, dir string) []string {
|
||||||
|
if m.Build == nil || len(m.Build.Artifacts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
in := func(p string) string {
|
||||||
|
if dir == "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return dir + "/" + p
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{in("module.json")}
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
if len(a.Paths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, p := range a.Paths {
|
||||||
|
entry := in(strings.Trim(path.Clean(p), "/")) + "/**"
|
||||||
|
if !seen[entry] {
|
||||||
|
seen[entry] = true
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withTrunkList stands in for the catalogue's trunk head: an empty list of predefined keys, readable, or not.
|
||||||
|
func withTrunkList(t *testing.T, readable bool) {
|
||||||
|
t.Helper()
|
||||||
|
was := predefinedAtTrunk
|
||||||
|
predefinedAtTrunk = func(context.Context, *inventory.Inventory) (predefinedHead, error) {
|
||||||
|
if !readable {
|
||||||
|
return predefinedHead{}, errors.New("the forge did not answer")
|
||||||
|
}
|
||||||
|
list, err := catalogue.ParsePredefined([]byte(`{}`))
|
||||||
|
return predefinedHead{List: list, Repository: "novox/mesh-catalog", Commit: "abcdef0123456789",
|
||||||
|
Generation: 1 << 40}, err
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { predefinedAtTrunk = was })
|
||||||
|
}
|
||||||
|
|
||||||
|
// aKeyFor is a done key of module at fp, of one archive with a reference, and the indexer's trunk value naming it at
|
||||||
|
// commit in repository/path.
|
||||||
|
func aKeyFor(module, version, fp, repository, path, commit string) (keyBuckets, sdkdelivery.Module) {
|
||||||
|
raw := `{"module":"` + module + `","version":"` + version + `","build":{"artifacts":[{"name":"a","kind":"archive","from":"files"}]},` +
|
||||||
|
`"resources":[{"id":"a","type":"archive","path":"/opt/` + module + `","artifact":"a"}]}`
|
||||||
|
digest := sdkdelivery.ManifestDigest([]byte(raw))
|
||||||
|
blob := shaOf(module + version)
|
||||||
|
k := builderkeys.Key{Module: module, Fingerprint: fp, Found: true, Revision: 1,
|
||||||
|
Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, By: "ace", Manifest: digest,
|
||||||
|
Artifacts: []sdkdelivery.BuiltArtifact{{Name: "a", Kind: "archive", Digest: blob,
|
||||||
|
Reference: "artifact-store://" + module + "/a/blobs/" + blob}}}}
|
||||||
|
return keyBuckets{keys: map[string]builderkeys.Key{module: k}, manifests: map[string]string{digest: raw}},
|
||||||
|
sdkdelivery.Module{FP2: fp, Manifest: digest, Repository: repository, Commit: commit, Path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fpOf(c byte) string { return "src2:" + strings.Repeat(string(c), 64) }
|
||||||
|
|
||||||
|
// A3, A4 (ADR 0498 §8, ADR 0501 §1-§3, §8): a done key of a trunk fingerprint records one build of today's layout,
|
||||||
|
// whose artifacts are the key's, and registers the module under the same rules of who may register as before.
|
||||||
|
func TestADoneKeyOfTheTrunkRegistersUnderTheSameRules(t *testing.T) {
|
||||||
|
open := theCatalogue(t) // sudo from novox/mesh-catalog modules/sudo, mesh-host from novox/mesh-host
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := open.inventory
|
||||||
|
withTrunkList(t, true)
|
||||||
|
|
||||||
|
// A known module from the repository and path the catalogue builds it from: registered from its key.
|
||||||
|
keys, trunk := aKeyFor("sudo", "2", fpOf('a'), "novox/mesh-catalog", "modules/sudo", "c0ffee0123456789")
|
||||||
|
_, kept, err := takeInKey(ctx, inv, keys, keyTake{Module: "sudo", Trunk: trunk, Ask: registrationAsk{Merge: trunk.Commit}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("0501 §1: a done key of sudo's trunk fingerprint was not taken in: %v", err)
|
||||||
|
}
|
||||||
|
if shelf, _ := inv.Catalogue(ctx); shelf["sudo"].Version != "2" {
|
||||||
|
t.Fatalf("0501 §1: sudo is %q", shelf["sudo"].Version)
|
||||||
|
}
|
||||||
|
got, found, err := inv.BuildByID(ctx, kept.ID)
|
||||||
|
if err != nil || !found || len(got.Made) != 1 || got.Made[0].Reference != kept.Made[0].Reference {
|
||||||
|
t.Fatalf("0498 §8: the recorded build is %+v (%v)", got, err)
|
||||||
|
}
|
||||||
|
if fps, err := inv.BuildSourceFingerprints(ctx, "sudo"); err != nil || fps["c0ffee0123456789"] != fpOf('a') {
|
||||||
|
t.Fatalf("0498 §8: the recorded build's fingerprint is %v (%v), not the key's", fps, err)
|
||||||
|
}
|
||||||
|
// Recording the same key again is one record.
|
||||||
|
if _, _, err := takeInKey(ctx, inv, keys, keyTake{Module: "sudo", Trunk: trunk, Ask: registrationAsk{Merge: trunk.Commit}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
builds, _ := inv.Builds(ctx, "sudo", 20)
|
||||||
|
n := 0
|
||||||
|
for _, b := range builds {
|
||||||
|
if b.ID == kept.ID {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("0498 §8: one key taken in twice is %d records", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A known module from another repository: refused, as a build of it was (ADR 0266).
|
||||||
|
other, otherTrunk := aKeyFor("sudo", "evil", fpOf('b'), "agent/sudo", "modules/sudo", "d00d0123456789ab")
|
||||||
|
if _, _, err := takeInKey(ctx, inv, other, keyTake{Module: "sudo", Trunk: otherTrunk,
|
||||||
|
Ask: registrationAsk{Merge: otherTrunk.Commit}}); !errors.Is(err, errNotItsSource) {
|
||||||
|
t.Fatalf("0501 §3: a known module from another repository was taken in: %v", err)
|
||||||
|
}
|
||||||
|
// A new module from a repository the catalogue builds from: registered.
|
||||||
|
zram, zramTrunk := aKeyFor("zram", "1", fpOf('c'), "novox/mesh-catalog", "modules/zram", "c0ffee0123456789")
|
||||||
|
if _, _, err := takeInKey(ctx, inv, zram, keyTake{Module: "zram", Trunk: zramTrunk, Ask: registrationAsk{Merge: zramTrunk.Commit}}); err != nil {
|
||||||
|
t.Fatalf("0501 §3: a new module from a built repository was refused: %v", err)
|
||||||
|
}
|
||||||
|
// A new module from a repository nothing is built from, with no Approve: refused.
|
||||||
|
tools, toolsTrunk := aKeyFor("agent-tools", "1", fpOf('d'), "agent/tools", "", "e0e0e0e0e0e0e0e0")
|
||||||
|
if _, _, err := takeInKey(ctx, inv, tools, keyTake{Module: "agent-tools", Trunk: toolsTrunk,
|
||||||
|
Ask: registrationAsk{Merge: toolsTrunk.Commit}}); !errors.Is(err, errNotItsSource) {
|
||||||
|
t.Fatalf("0501 §3: a new repository's module was taken in with no warrant: %v", err)
|
||||||
|
}
|
||||||
|
// At the terminal the operator registers it (ADR 0501 §4).
|
||||||
|
if _, _, err := takeInKey(ctx, inv, tools, keyTake{Module: "agent-tools", Trunk: toolsTrunk,
|
||||||
|
Ask: registrationAsk{Terminal: true}}); err != nil {
|
||||||
|
t.Fatalf("0501 §4: the terminal's registration was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0501 §2: a key only a candidate names registers nothing: the trunk value names another fingerprint, whose key is
|
||||||
|
// not there, and the take-in waits.
|
||||||
|
func TestAKeyOnlyACandidateNamesRegistersNothing(t *testing.T) {
|
||||||
|
open := theCatalogue(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
withTrunkList(t, true)
|
||||||
|
keys, trunk := aKeyFor("sudo", "3", fpOf('e'), "novox/mesh-catalog", "modules/sudo", "c0ffee0123456789")
|
||||||
|
trunk.FP2 = fpOf('f') // the trunk names another fingerprint than the candidate's done key
|
||||||
|
_, _, err := takeInKey(ctx, open.inventory, keys, keyTake{Module: "sudo", Trunk: trunk, Ask: registrationAsk{Merge: trunk.Commit}})
|
||||||
|
if !errors.Is(err, errKeyWaits) {
|
||||||
|
t.Fatalf("0501 §2: %v", err)
|
||||||
|
}
|
||||||
|
if shelf, _ := open.inventory.Catalogue(ctx); shelf["sudo"].Version != "1" {
|
||||||
|
t.Fatalf("0501 §2: a candidate's key registered sudo %q", shelf["sudo"].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0501 §8: a take-in that cannot read the list of predefined keys at the catalogue's trunk head waits, says so and
|
||||||
|
// registers nothing.
|
||||||
|
func TestATakeInWithoutTheTrunksListWaits(t *testing.T) {
|
||||||
|
open := theCatalogue(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
withTrunkList(t, false)
|
||||||
|
keys, trunk := aKeyFor("sudo", "4", fpOf('1'), "novox/mesh-catalog", "modules/sudo", "c0ffee0123456789")
|
||||||
|
_, _, err := takeInKey(ctx, open.inventory, keys, keyTake{Module: "sudo", Trunk: trunk, Ask: registrationAsk{Merge: trunk.Commit}})
|
||||||
|
if !errors.Is(err, errKeyWaits) || !strings.Contains(err.Error(), "predefined keys") {
|
||||||
|
t.Fatalf("0501 §8: %v", err)
|
||||||
|
}
|
||||||
|
if shelf, _ := open.inventory.Catalogue(ctx); shelf["sudo"].Version != "1" {
|
||||||
|
t.Fatalf("0501 §8: registered %q", shelf["sudo"].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A3 (the lead's decision on Against and Read): a key's record keeps the collector's keep set as it was and its read
|
||||||
|
// repositories are its manifest's, fresh at every key.
|
||||||
|
func TestAKeysRecordKeepsTheKeepSetAndReadsItsManifest(t *testing.T) {
|
||||||
|
open := theCatalogue(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := open.inventory
|
||||||
|
withTrunkList(t, true)
|
||||||
|
before, err := inv.KeepSet(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw := `{"module":"sudo","version":"5","build":{"artifacts":[{"name":"img","kind":"image","from":"Dockerfile",` +
|
||||||
|
`"context":{"repository":"novox/elsewhere","seat":"git"}}]},"resources":[{"id":"run","type":"container","artifact":"img"}]}`
|
||||||
|
digest := sdkdelivery.ManifestDigest([]byte(raw))
|
||||||
|
k := builderkeys.Key{Module: "sudo", Fingerprint: fpOf('2'), Found: true, Revision: 1,
|
||||||
|
Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, By: "g14", Manifest: digest,
|
||||||
|
Artifacts: []sdkdelivery.BuiltArtifact{{Name: "img", Kind: "image", Digest: shaOf("img5"),
|
||||||
|
Reference: "artifact-store://sudo/img@" + shaOf("img5")}}}}
|
||||||
|
keys := keyBuckets{keys: map[string]builderkeys.Key{"sudo": k}, manifests: map[string]string{digest: raw}}
|
||||||
|
trunk := sdkdelivery.Module{FP2: fpOf('2'), Manifest: digest, Repository: "novox/mesh-catalog",
|
||||||
|
Commit: "c0ffee0123456789", Path: "modules/sudo"}
|
||||||
|
if _, _, err := takeInKey(ctx, inv, keys, keyTake{Module: "sudo", Trunk: trunk, Ask: registrationAsk{Merge: trunk.Commit}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := inv.KeepSet(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for reference, why := range before {
|
||||||
|
if !reflect.DeepEqual(after[reference], why) && !strings.Contains(strings.Join(after[reference], ","), strings.Join(why, ",")) {
|
||||||
|
t.Errorf("the keep set changed for %s: %v, was %v", reference, after[reference], why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
read, err := inv.ReadRepositories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, r := range read["sudo"] {
|
||||||
|
if !r.Own && r.Repository == "novox/elsewhere" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
text, _ := json.Marshal(read["sudo"])
|
||||||
|
t.Fatalf("the key's record does not read its manifest's context: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A9 (ADR 0453 §6, ADR 0499 consequences): at the switch, a key's build whose every artifact has the digest of the
|
||||||
|
// build-agent build the mesh holds (`src1:`) is no move: it is registered with that build's artifacts, and a walk sends
|
||||||
|
// nothing for it.
|
||||||
|
func TestAKeyWithTheHeldBuildsDigestsIsNoMove(t *testing.T) {
|
||||||
|
open := theCatalogue(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := open.inventory
|
||||||
|
withTrunkList(t, true)
|
||||||
|
keys, trunk := aKeyFor("sudo", "6", fpOf('6'), "novox/mesh-catalog", "modules/sudo", "c0ffee0123456789")
|
||||||
|
k := keys.keys["sudo"]
|
||||||
|
held := inventory.Build{ID: "build-sudo-old", Module: "sudo", Repository: "novox/mesh-catalog", Path: "modules/sudo",
|
||||||
|
Commit: "0ld0ld0ld0ld0ld0", SourceFingerprint: "src1:" + strings.Repeat("9", 64),
|
||||||
|
Made: []inventory.Artifact{{Name: "a", Kind: "archive", Reference: k.Build.Artifacts[0].Reference}}}
|
||||||
|
held.Manifest, _ = json.Marshal(catalogue.Manifest{Module: "sudo", Version: "held"})
|
||||||
|
if err := inv.RecordBuild(ctx, held); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, kept, err := takeInKey(ctx, inv, keys, keyTake{Module: "sudo", Trunk: trunk, Ask: registrationAsk{Merge: trunk.Commit}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
same, err := inv.SameSourceCommits(ctx, "sudo", kept.ID)
|
||||||
|
if err != nil || !same["0ld0ld0ld0ld0ld0"] {
|
||||||
|
t.Fatalf("A9: the key's build does not stand on the held build of the same digests: %v %v", same, err)
|
||||||
|
}
|
||||||
|
if shelf, _ := inv.Catalogue(ctx); shelf["sudo"].Version != "held" {
|
||||||
|
t.Fatalf("A9: sudo is registered as %q, not with the held build's artifacts", shelf["sudo"].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A13 against the store: the switch's list reads the build the mesh holds of each module and the builder's key of its
|
||||||
|
// trunk fingerprint, and names each module unmoved, moved or not known.
|
||||||
|
func TestTheSwitchsListReadsTheHeldBuildsAndTheKeys(t *testing.T) {
|
||||||
|
open := theCatalogue(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := open.inventory
|
||||||
|
keys, trunk := aKeyFor("sudo", "1", fpOf('b'), "novox/mesh-catalog", "modules/sudo", "c0ffee0123456789")
|
||||||
|
k := keys.keys["sudo"]
|
||||||
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-sudo-held", Module: "sudo", Repository: "novox/mesh-catalog",
|
||||||
|
Commit: "c0ffee0123456789", Made: []inventory.Artifact{{Name: "a", Kind: "archive",
|
||||||
|
Reference: k.Build.Artifacts[0].Reference}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reads := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@": trunk}, keys: map[string]builderkeys.Key{"sudo": k}}
|
||||||
|
lines, err := switchList(ctx, inv, reads)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, l := range lines {
|
||||||
|
got[l.Module] = l.Verdict
|
||||||
|
}
|
||||||
|
if got["sudo"] != switchUnmoved || got["mesh-host"] != switchUnknown {
|
||||||
|
t.Fatalf("A13: the switch's list is %+v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyBuckets is the builder's and the indexer's buckets as a take-in reads them, for a test: keys by module, manifests by
|
||||||
|
// digest.
|
||||||
|
type keyBuckets struct {
|
||||||
|
keys map[string]builderkeys.Key
|
||||||
|
manifests map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k keyBuckets) Build(_ context.Context, module, fp string) (builderkeys.Key, error) {
|
||||||
|
if key, ok := k.keys[module]; ok && key.Fingerprint == fp {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
return builderkeys.Key{Module: module, Fingerprint: fp}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k keyBuckets) Manifest(_ context.Context, digest string) (sdkdelivery.Manifest, error) {
|
||||||
|
raw, ok := k.manifests[digest]
|
||||||
|
if !ok {
|
||||||
|
return sdkdelivery.Manifest{}, errors.New("the indexer holds no manifest " + digest)
|
||||||
|
}
|
||||||
|
return sdkdelivery.Manifest{Module: "x", JSON: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyFP = "src2:" + "1111111111111111111111111111111111111111111111111111111111111111"
|
||||||
|
|
||||||
|
// aKeyOf is a done key of module x at keyFP for the manifest raw, with these artifacts.
|
||||||
|
func aKeyOf(raw string, revision uint64, made ...sdkdelivery.BuiltArtifact) (keyBuckets, sdkdelivery.Module) {
|
||||||
|
digest := sdkdelivery.ManifestDigest([]byte(raw))
|
||||||
|
k := builderkeys.Key{Module: "x", Fingerprint: keyFP, Found: true, Revision: revision,
|
||||||
|
Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, By: "ace", Manifest: digest, Artifacts: made}}
|
||||||
|
trunk := sdkdelivery.Module{FP2: keyFP, Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c0ffee0123456789",
|
||||||
|
Path: "modules/x"}
|
||||||
|
return keyBuckets{keys: map[string]builderkeys.Key{"x": k}, manifests: map[string]string{digest: raw}}, trunk
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsBundle = `{"module":"x","version":"1","tools":["x_read"],
|
||||||
|
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["dist/index.js"]},
|
||||||
|
{"name":"img","kind":"image","from":"Dockerfile","context":{"repository":"novox/other","seat":"git"}}]},
|
||||||
|
"resources":[{"id":"run","type":"container","artifact":"img"}]}`
|
||||||
|
|
||||||
|
func shaOf(b string) string {
|
||||||
|
sum := sha256.Sum256([]byte(b))
|
||||||
|
return "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A3 (ADR 0498 §8, ADR 0501 §1): a done key whose every artifact names a reference is one recorded build, its artifacts
|
||||||
|
// the key's, its launchers by the build modules' rule, its reads from its manifest, the bases its last build stood on
|
||||||
|
// carried; recorded twice it is one record.
|
||||||
|
func TestADoneKeyIsOneRecordedBuildOfItsArtifacts(t *testing.T) {
|
||||||
|
keys, trunk := aKeyOf(tsBundle, 3,
|
||||||
|
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")})
|
||||||
|
previous := []inventory.Build{{ID: "build-1", Module: "x", Against: []string{"artifact-store://base@sha256:aa"}}}
|
||||||
|
take := keyTake{Module: "x", Trunk: trunk}
|
||||||
|
kept, resolved, err := buildOfKey(t.Context(), keys, take, keyContext{previous: previous})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if kept.ID != "key-1111111111111111111111111111111111111111111111111111111111111111-3" {
|
||||||
|
t.Errorf("the record's id is %s", kept.ID)
|
||||||
|
}
|
||||||
|
again, _, _ := buildOfKey(t.Context(), keys, take, keyContext{previous: previous})
|
||||||
|
if again.ID != kept.ID {
|
||||||
|
t.Errorf("one key recorded twice is two records: %s and %s", kept.ID, again.ID)
|
||||||
|
}
|
||||||
|
if kept.SourceFingerprint != keyFP || kept.Commit != trunk.Commit || kept.Path != "modules/x" || kept.On != "ace" ||
|
||||||
|
kept.Module != "x" || kept.Repository != "novox/mesh-catalog" {
|
||||||
|
t.Errorf("the record: %+v", kept)
|
||||||
|
}
|
||||||
|
if len(kept.Made) != 2 || kept.Made[0].Reference != "artifact-store://x/tools/blobs/"+shaOf("t") ||
|
||||||
|
kept.Made[1].Reference != "artifact-store://x/img@"+shaOf("i") || kept.Made[1].Kind != "image" {
|
||||||
|
t.Errorf("Made is not the key's artifacts: %+v", kept.Made)
|
||||||
|
}
|
||||||
|
if len(kept.Read) != 1 || kept.Read[0].Repository != "novox/other" {
|
||||||
|
t.Errorf("Read is not the manifest's contexts: %+v", kept.Read)
|
||||||
|
}
|
||||||
|
if kept.Sources != nil || kept.Mirrored != nil {
|
||||||
|
t.Errorf("a key's record says build sources or mirrored bases it cannot know: %+v %+v", kept.Sources, kept.Mirrored)
|
||||||
|
}
|
||||||
|
if len(kept.Against) != 1 || kept.Against[0] != "artifact-store://base@sha256:aa" {
|
||||||
|
t.Errorf("the bases the last build stood on are not carried: %v", kept.Against)
|
||||||
|
}
|
||||||
|
if len(resolved.Bundles) != 1 || resolved.Bundles[0].Launchers["dist/index.js"] != "dist/index.serve.mjs" {
|
||||||
|
t.Errorf("the bundle's launchers: %+v", resolved.Bundles)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(kept.Manifest, &m); err != nil || !strings.Contains(string(kept.Manifest), shaOf("i")) {
|
||||||
|
t.Errorf("the recorded manifest names no image digest: %s", kept.Manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A3 (ADR 0498 §8): a key with an artifact naming no reference, a key not done and a missing key are waited for, said;
|
||||||
|
// a failed key fails in the builder's words; a module with no fingerprint of the new form waits, naming why.
|
||||||
|
func TestAKeyNotReadyWaitsAndAFailedKeyFails(t *testing.T) {
|
||||||
|
noRef, trunk := aKeyOf(tsBundle, 1,
|
||||||
|
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")})
|
||||||
|
take := keyTake{Module: "x", Trunk: trunk}
|
||||||
|
_, _, err := buildOfKey(t.Context(), noRef, take, keyContext{})
|
||||||
|
if !errors.Is(err, errKeyWaits) || !strings.Contains(err.Error(), "waits for the reference of img") {
|
||||||
|
t.Errorf("a key without a reference: %v", err)
|
||||||
|
}
|
||||||
|
building := noRef
|
||||||
|
k := building.keys["x"]
|
||||||
|
k.Build = sdkdelivery.BuilderBuild{State: sdkdelivery.BuildBuilding, By: "g14"}
|
||||||
|
building.keys = map[string]builderkeys.Key{"x": k}
|
||||||
|
if _, _, err := buildOfKey(t.Context(), building, take, keyContext{}); !errors.Is(err, errKeyWaits) ||
|
||||||
|
!strings.Contains(err.Error(), "building on g14") {
|
||||||
|
t.Errorf("a building key: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := buildOfKey(t.Context(), keyBuckets{}, take, keyContext{}); !errors.Is(err, errKeyWaits) ||
|
||||||
|
!strings.Contains(err.Error(), "no build of x") {
|
||||||
|
t.Errorf("no key: %v", err)
|
||||||
|
}
|
||||||
|
k.Build = sdkdelivery.BuilderBuild{State: sdkdelivery.BuildFailed, Error: "digests differ: ace sha256:1, g14 sha256:2"}
|
||||||
|
failed := keyBuckets{keys: map[string]builderkeys.Key{"x": k}}
|
||||||
|
if _, _, err := buildOfKey(t.Context(), failed, take, keyContext{}); !errors.Is(err, errKeyFailed) ||
|
||||||
|
!strings.Contains(err.Error(), "digests differ: ace sha256:1, g14 sha256:2") {
|
||||||
|
t.Errorf("a failed key: %v", err)
|
||||||
|
}
|
||||||
|
none := take
|
||||||
|
none.Trunk.FP2, none.Trunk.None2 = "", "no lock"
|
||||||
|
if _, _, err := buildOfKey(t.Context(), noRef, none, keyContext{}); !errors.Is(err, errKeyWaits) ||
|
||||||
|
!strings.Contains(err.Error(), "no lock") {
|
||||||
|
t.Errorf("no fingerprint: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0501 §7: a manifest word this controller does not read waits for the controller that reads it; never fails.
|
||||||
|
func TestAManifestWordTheControllerDoesNotReadWaits(t *testing.T) {
|
||||||
|
raw := `{"module":"x","version":"1","not-a-word-yet":true,"build":{"artifacts":[{"name":"a","kind":"archive","from":"files"}]},
|
||||||
|
"resources":[{"id":"a","type":"archive","path":"/opt/x","artifact":"a"}]}`
|
||||||
|
keys, trunk := aKeyOf(raw, 1, sdkdelivery.BuiltArtifact{Name: "a", Kind: "archive", Digest: shaOf("a"),
|
||||||
|
Reference: "artifact-store://x/a/blobs/" + shaOf("a")})
|
||||||
|
_, _, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk}, keyContext{})
|
||||||
|
if !errors.Is(err, errKeyWaits) || !strings.Contains(err.Error(), `"not-a-word-yet"`) {
|
||||||
|
t.Fatalf("an unread word: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func packed(t *testing.T, files map[string]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
z := gzip.NewWriter(&buf)
|
||||||
|
w := tar.NewWriter(z)
|
||||||
|
for name, text := range files {
|
||||||
|
if err := w.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(text)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := z.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A templated module's texts are read back from the archive the store holds under the key's reference; an archive the
|
||||||
|
// store does not answer is waited for (the lead's Q1b).
|
||||||
|
func TestATemplatedModulesTextsAreReadFromItsArchive(t *testing.T) {
|
||||||
|
raw := `{"module":"x","version":"1","templates":"templates",
|
||||||
|
"build":{"artifacts":[{"name":"templates","kind":"archive","from":"templates"}]},
|
||||||
|
"resources":[{"id":"main","type":"file","path":"/etc/x.conf","mode":"0644","template":"templates/x.conf"}]}`
|
||||||
|
body := packed(t, map[string]string{"x.conf": "listen 1\n"})
|
||||||
|
sum := sha256.Sum256(body)
|
||||||
|
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
keys, trunk := aKeyOf(raw, 1, sdkdelivery.BuiltArtifact{Name: "templates", Kind: "archive", Digest: digest,
|
||||||
|
Reference: "artifact-store://x/templates/blobs/" + digest})
|
||||||
|
asked := ""
|
||||||
|
archive := func(_ context.Context, reference string) ([]byte, error) { asked = reference; return body, nil }
|
||||||
|
_, resolved, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk}, keyContext{archive: archive})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if asked != "artifact-store://x/templates/blobs/"+digest {
|
||||||
|
t.Errorf("the archive asked: %q", asked)
|
||||||
|
}
|
||||||
|
if text, _ := json.Marshal(resolved.Resources); !strings.Contains(string(text), "listen 1") {
|
||||||
|
t.Errorf("the template's text is not in the file: %s", text)
|
||||||
|
}
|
||||||
|
gone := func(context.Context, string) ([]byte, error) { return nil, errors.New("the store does not hold it") }
|
||||||
|
if _, _, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk}, keyContext{archive: gone}); !errors.Is(err, errKeyWaits) ||
|
||||||
|
!strings.Contains(err.Error(), "cannot be read from the artifact store") {
|
||||||
|
t.Errorf("an archive the store does not answer: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The launchers a key's bundle is resolved with are mesh-sdk's one rule (buildstep.Launchers), the names
|
||||||
|
// mesh-build-node writes: a TypeScript bundle's entrypoint x.js runs as x.serve.mjs, a Go bundle has none.
|
||||||
|
func TestALaunchersRuleIsTheBuildModules(t *testing.T) {
|
||||||
|
m, err := catalogue.ParseManifest([]byte(tsBundle))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
k := builderkeys.Key{Build: sdkdelivery.BuilderBuild{Artifacts: []sdkdelivery.BuiltArtifact{{Name: "tools", Digest: "sha256:1",
|
||||||
|
Reference: "artifact-store://x/tools/blobs/sha256:1"}}}}
|
||||||
|
built, err := builtOfKey(t.Context(), nil, m, k)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if built[0].Launchers["dist/index.js"] != "dist/index.serve.mjs" || built[0].Kind != "bundle" {
|
||||||
|
t.Fatalf("built: %+v", built[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-14
@@ -54,6 +54,8 @@ func run() error {
|
|||||||
return fmt.Errorf("no command given")
|
return fmt.Errorf("no command given")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The bus's users are composed from what each module announced it keeps (novox/hq ADR 0495, issue 1790).
|
||||||
|
inventory.Announcements = readAnnouncements
|
||||||
// Every connection this process dials says what it is (novox/hq issue 327).
|
// Every connection this process dials says what it is (novox/hq issue 327).
|
||||||
broker.ConnectionName = connectionName(args[0], os.Getenv(verbVar))
|
broker.ConnectionName = connectionName(args[0], os.Getenv(verbVar))
|
||||||
|
|
||||||
@@ -73,8 +75,13 @@ func run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "build":
|
case "switch":
|
||||||
return buildCommand(ctx, args[1:])
|
return switchCommand(ctx, args[1:])
|
||||||
|
case "register":
|
||||||
|
return registerCommand(ctx, args[1:])
|
||||||
|
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":
|
case "builder":
|
||||||
return builderCommand(ctx, args[1:])
|
return builderCommand(ctx, args[1:])
|
||||||
case "board":
|
case "board":
|
||||||
@@ -98,14 +105,12 @@ func run() error {
|
|||||||
return cancelCommand(ctx, args[1:])
|
return cancelCommand(ctx, args[1:])
|
||||||
case "clear":
|
case "clear":
|
||||||
return clearCommand(ctx, args[1:])
|
return clearCommand(ctx, args[1:])
|
||||||
case "rebuild":
|
|
||||||
return rebuildCommand(ctx, args[1:])
|
|
||||||
case "replay":
|
case "replay":
|
||||||
return replayCommand(ctx, args[1:])
|
return replayCommand(ctx, args[1:])
|
||||||
case "kill":
|
case "kill":
|
||||||
return killCommand(ctx, args[1:])
|
return killCommand(ctx, args[1:])
|
||||||
case "forget-checks":
|
case "forget-checks":
|
||||||
return forgetChecksCommand(ctx, args[1:])
|
return removedByName("forget-checks")
|
||||||
case "try":
|
case "try":
|
||||||
return tryCommand(ctx, args[1:])
|
return tryCommand(ctx, args[1:])
|
||||||
case "pause", "resume":
|
case "pause", "resume":
|
||||||
@@ -232,8 +237,8 @@ func run() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func usage() {
|
// usageText is the terminal's help: every command it has, and none it refuses as removed (removedByName).
|
||||||
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
var usageText = `mesh-controller — the control plane
|
||||||
|
|
||||||
On a node the operator types these as 'mesh-cli <command>' (in zsh, 'nox <command>'): asked
|
On a node the operator types these as 'mesh-cli <command>' (in zsh, 'nox <command>'): asked
|
||||||
through the node's engine, and run as the controller's terminal only on the control-node
|
through the node's engine, and run as the controller's terminal only on the control-node
|
||||||
@@ -307,15 +312,14 @@ func usage() {
|
|||||||
operator key make [--out <file>] make the operator's sealing key, off the mesh; private half to the file only
|
operator key make [--out <file>] make the operator's sealing key, off the mesh; private half to the file only
|
||||||
operator key set <public> [--replace] tell the mesh which operator key to seal to
|
operator key set <public> [--replace] tell the mesh which operator key to seal to
|
||||||
operator key show the operator key, and what it can recover
|
operator key show the operator key, and what it can recover
|
||||||
build <repository> [--ref R] have a build machine build it, and record what came out
|
register <module> <owner>/<repository> <path> register a module from a repository the catalogue does not
|
||||||
build --behind build every module the mesh holds older than its source
|
build, from the builder's done key of its fingerprint (ADR 0501 §4)
|
||||||
build --on <module> rebuild every module that stands on this module's artifacts, bases first
|
switch [--json] the switch's list: each module, the build it runs, and what the builder holds
|
||||||
builds [<module>] what has been built lately, and what came of it
|
builds [<module>] 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
|
queue [--json] every ask in the build queue: waiting, in flight (where, how long), dead
|
||||||
cancel <id> drop a waiting or dead ask; recorded failed, cancelled by hand
|
cancel <id> 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
|
clear [--dead] cancel every waiting ask (and the dead ones); never one in flight
|
||||||
rebuild <module|build-id> ask the module's source again, or that build's, under a new id
|
replay <build-id> that build's commit again, a dry run: what a build of it makes
|
||||||
replay <build-id> [--register [--older]] that build's commit again; a dry run unless --register
|
|
||||||
kill <id> end a build where it runs; recorded failed, killed by hand
|
kill <id> end a build where it runs; recorded failed, killed by hand
|
||||||
pause [<node>] / resume [<node>] the build seat's holder there, or every holder, takes nothing new / again
|
pause [<node>] / resume [<node>] the build seat's holder there, or every holder, takes nothing new / again
|
||||||
plans retry <id> ask a failed plan's failed builds again, and carry the plan on
|
plans retry <id> ask a failed plan's failed builds again, and carry the plan on
|
||||||
@@ -350,10 +354,13 @@ func usage() {
|
|||||||
version what this binary is
|
version what this binary is
|
||||||
|
|
||||||
Each context reaches its own store through its own credential (novox/hq ADR 0008), named
|
Each context reaches its own store through its own credential (novox/hq ADR 0008), named
|
||||||
`+store.Variable("<context>")+` — or `+store.FileVariable("<context>")+`, naming a file that holds
|
` + store.Variable("<context>") + ` — or ` + store.FileVariable("<context>") + `, naming a file that holds
|
||||||
the same thing and keeps the password out of the environment. This process holds:
|
the same thing and keeps the password out of the environment. This process holds:
|
||||||
|
|
||||||
`)
|
`
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprint(os.Stderr, usageText)
|
||||||
for _, c := range held {
|
for _, c := range held {
|
||||||
fmt.Fprintf(os.Stderr, " %-12s database %-12s from %s\n",
|
fmt.Fprintf(os.Stderr, " %-12s database %-12s from %s\n",
|
||||||
c.name, store.Database(c.name), store.Variable(c.name))
|
c.name, store.Database(c.name), store.Variable(c.name))
|
||||||
@@ -475,3 +482,26 @@ func connectionName(command, verb string) string {
|
|||||||
}
|
}
|
||||||
return "mesh-controller command " + command
|
return "mesh-controller command " + command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removedByName refuses a command the controller no longer has, naming what replaced it (novox/hq ADR 0501 §4, §5;
|
||||||
|
// ADR 0429 §18; ADR 0503 §3; issue 1851, A11, and issue 1318).
|
||||||
|
func removedByName(command string) error {
|
||||||
|
replacement := map[string]string{
|
||||||
|
"build": "the controller asks no build (ADR 0501 §5): the builder builds every fingerprint the indexer writes, a " +
|
||||||
|
"module is registered from its done key, and a branch is tried with `try`; a module from a repository the " +
|
||||||
|
"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",
|
||||||
|
"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)",
|
||||||
|
}[command]
|
||||||
|
return &removedCommand{msg: fmt.Sprintf("`%s` is removed: %s", command, replacement)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removedCommand is a command or verb the controller no longer has: a refusal that is the verb's whole answer, not a
|
||||||
|
// verb of a newer build this one cannot run.
|
||||||
|
type removedCommand struct{ msg string }
|
||||||
|
|
||||||
|
func (r *removedCommand) Error() string { return r.msg }
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ type mergeCheckInput struct {
|
|||||||
// group are the other heads of a delivery group composed with this one (novox/hq ADR 0239), each laid
|
// 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.
|
// over the mesh after the one before: the group judged as one future state.
|
||||||
group []groupChange
|
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 is a PostgreSQL the gate may create and drop databases in.
|
||||||
admin string
|
admin string
|
||||||
// beside is the directory holding the core clones beside the check (MESH_CHECK_BESIDE), for finding moves.
|
// beside is the directory holding the core clones beside the check (MESH_CHECK_BESIDE), for finding moves.
|
||||||
@@ -189,6 +192,8 @@ func mergeGateCommand(ctx context.Context, args []string) error {
|
|||||||
"--changed, for a list too long for an argument (novox/hq issue 1247)")
|
"--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 "+
|
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)")
|
"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")
|
asJSON := set.Bool("json", false, "print the verdict as JSON")
|
||||||
if _, err := parseAround(set, args); err != nil {
|
if _, err := parseAround(set, args); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -208,6 +213,10 @@ func mergeGateCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
held, err := readHeld(*heldFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
out := io.Writer(os.Stdout)
|
out := io.Writer(os.Stdout)
|
||||||
stdout := os.Stdout
|
stdout := os.Stdout
|
||||||
if *asJSON {
|
if *asJSON {
|
||||||
@@ -223,7 +232,7 @@ func mergeGateCommand(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: changedList,
|
v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: changedList,
|
||||||
group: others, admin: *admin, say: out, beside: os.Getenv("MESH_CHECK_BESIDE")})
|
group: others, held: held, admin: *admin, say: out, beside: os.Getenv("MESH_CHECK_BESIDE")})
|
||||||
os.Stdout = stdout
|
os.Stdout = stdout
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -387,7 +396,16 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(added)
|
sort.Strings(added)
|
||||||
after, tried, notes, err := composeEveryMachine(ctx, in, change, changeSources, 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 {
|
if err != nil {
|
||||||
return v, fmt.Errorf("the mesh with the change could not be raised: %w", err)
|
return v, fmt.Errorf("the mesh with the change could not be raised: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -526,3 +526,57 @@ func TestAMemberJudgedAloneSeesAMoveThroughTheClonesBeside(t *testing.T) {
|
|||||||
t.Errorf("nothing beside, yet moves %v", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/conditions"
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/licences"
|
"github.com/novox/mesh-controller/internal/licences"
|
||||||
@@ -55,9 +56,18 @@ func aMeshOnTheStore(t *testing.T) *stores {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(open.Close)
|
t.Cleanup(open.Close)
|
||||||
|
// The work this mesh started in the background ends before its stores close (cleanups run last first).
|
||||||
|
t.Cleanup(background.Wait)
|
||||||
// A condition store of its own, held in memory (novox/hq to-be 45 §2): status leads with what is
|
// A condition store of its own, held in memory (novox/hq to-be 45 §2): status leads with what is
|
||||||
// open, and a mesh with no bus would otherwise read as one whose conditions cannot be read.
|
// open, and a mesh with no bus would otherwise read as one whose conditions cannot be read.
|
||||||
withConditionsInMemory(t)
|
withConditionsInMemory(t)
|
||||||
|
// The indexer holds no module unless a test puts one on its bus (aBuilderOnTheBus): an assignment's look at it
|
||||||
|
// reads "not held" on a mesh with no bus of its own.
|
||||||
|
wasIndexed := indexedModule
|
||||||
|
indexedModule = func(context.Context, string) (sdkdelivery.Module, bool, error) {
|
||||||
|
return sdkdelivery.Module{}, false, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { indexedModule = wasIndexed })
|
||||||
for _, m := range provided {
|
for _, m := range provided {
|
||||||
if err := open.inventory.Provide(t.Context(), m); err != nil {
|
if err := open.inventory.Provide(t.Context(), m); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -52,23 +52,22 @@ type deliveryMerge struct {
|
|||||||
Asker string `json:"asker,omitempty"`
|
Asker string `json:"asker,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// askerOf is the asker whose warrant the merge is read under: the one the announcement names, mesh-delivery when it
|
// askerOf is the asker whose warrant the merge is read under: the one the announcement names. One that names none is
|
||||||
// names none.
|
// from mesh-delivery of before the gate, whose warrant is no longer taken (novox/hq ADR 0450 §8; issue 1318).
|
||||||
func (d deliveryMerge) askerOf() string {
|
func (d deliveryMerge) askerOf() string { return d.Asker }
|
||||||
if d.Asker == "" {
|
|
||||||
return theDelivery
|
|
||||||
}
|
|
||||||
return d.Asker
|
|
||||||
}
|
|
||||||
|
|
||||||
// askerAccepted is whether the controller takes a warrant from this asker for a merge: the teller, and mesh-delivery
|
// askerAccepted is whether the controller takes a warrant from this asker for a merge: the teller alone, since the gate
|
||||||
// until the gate takes the seat over (novox/hq ADR 0437 §6). Any other registers no move.
|
// took the delivery seat over (novox/hq ADR 0437 §6, ADR 0450 §8; issue 1318). Any other registers no move.
|
||||||
func askerAccepted(asker string) error {
|
func askerAccepted(asker string) error {
|
||||||
if asker == theDelivery || asker == sdkdelivery.TellerModule {
|
if asker == sdkdelivery.TellerModule {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("the merge names the asker %q, and the controller takes a warrant only from %s and %s",
|
if asker == "" {
|
||||||
asker, sdkdelivery.TellerModule, theDelivery)
|
return fmt.Errorf("the merge names no asker, as mesh-delivery's of before the gate did, and the controller takes "+
|
||||||
|
"a warrant only from %s (ADR 0450 §8)", sdkdelivery.TellerModule)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("the merge names the asker %q, and the controller takes a warrant only from %s (ADR 0450 §8)",
|
||||||
|
asker, sdkdelivery.TellerModule)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDeliveryMerge reads an announcement, refusing one that names no pull request, head or ask.
|
// parseDeliveryMerge reads an announcement, refusing one that names no pull request, head or ask.
|
||||||
@@ -265,25 +264,32 @@ var errNotAMerge = errors.New("the build is not of a merge")
|
|||||||
// (ADR 0420 §7): the build is of a merge, that merge is a pull request mesh-delivery announced it made, and the
|
// (ADR 0420 §7): the build is of a merge, that merge is a pull request mesh-delivery announced it made, and the
|
||||||
// ask it made it on holds an Approve by the operator of that head. Answers why it was accepted; an error says why
|
// ask it made it on holds an Approve by the operator of that head. Answers why it was accepted; an error says why
|
||||||
// it was not, and the refusal of ADR 0266 stands.
|
// it was not, and the refusal of ADR 0266 stands.
|
||||||
func approvedMove(ctx context.Context, inv registrations, forms *sourceForms, buildID string,
|
func approvedMove(ctx context.Context, inv registrations, forms *sourceForms, ask registrationAsk,
|
||||||
built inventory.Source) (string, error) {
|
built inventory.Source) (string, error) {
|
||||||
r, found, err := inv.BuildRequestByID(ctx, buildID)
|
commit := ask.Merge
|
||||||
if err != nil {
|
if ask.Build != "" {
|
||||||
return "", err
|
r, found, err := inv.BuildRequestByID(ctx, ask.Build)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !found || r.For != "merge" || r.Commit == "" {
|
||||||
|
return "", errNotAMerge
|
||||||
|
}
|
||||||
|
commit = r.Commit
|
||||||
}
|
}
|
||||||
if !found || r.For != "merge" || r.Commit == "" {
|
if commit == "" {
|
||||||
return "", errNotAMerge
|
return "", errNotAMerge
|
||||||
}
|
}
|
||||||
owner, name, onForge := forms.onTheForge(built.Repository, built.Seat)
|
owner, name, onForge := forms.onTheForge(built.Repository, built.Seat)
|
||||||
if !onForge {
|
if !onForge {
|
||||||
return "", errors.New("the repository is not on the mesh's forge")
|
return "", errors.New("the repository is not on the mesh's forge")
|
||||||
}
|
}
|
||||||
merged, known, err := inv.MergeOf(ctx, owner+"/"+name, r.Commit)
|
merged, known, err := inv.MergeOf(ctx, owner+"/"+name, commit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if !known {
|
if !known {
|
||||||
return "", fmt.Errorf("the controller kept no merge of %s/%s at %.8s", owner, name, r.Commit)
|
return "", fmt.Errorf("the controller kept no merge of %s/%s at %.8s", owner, name, commit)
|
||||||
}
|
}
|
||||||
return approvedAnnounced(ctx, inv, owner, name, announcedOf(merged))
|
return approvedAnnounced(ctx, inv, owner, name, announcedOf(merged))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,18 +48,19 @@ func TestAMergeNamingTheTellerIsMovedOnTheTellersWarrant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1213/AC2: an announcement naming mesh-delivery, or none (today's module): read under mesh-delivery, registered.
|
// 1318 (ADR 0450 §8): a merge naming no asker, or the asker mesh-delivery of before the gate, registers no move; the
|
||||||
func TestAMergeNamingNoAskerIsMovedOnMeshDeliverysWarrant(t *testing.T) {
|
// teller's is taken, and its warrant read under its name.
|
||||||
|
func TestAMergeOfMeshDeliveryOrNoAskerMovesNothing(t *testing.T) {
|
||||||
for _, named := range []string{"", theDelivery} {
|
for _, named := range []string{"", theDelivery} {
|
||||||
a := askedBy(t, named, theDelivery)
|
a := askedBy(t, named, theDelivery)
|
||||||
if _, err := movedOnApprove(a.merge, &a.ask, &a.warrant); err != nil {
|
if _, err := movedOnApprove(a.merge, &a.ask, &a.warrant); err == nil || !strings.Contains(err.Error(), "ADR 0450 §8") {
|
||||||
t.Errorf("asker %q: refused: %v", named, err)
|
t.Errorf("asker %q: %v", named, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
was := routerAskOf
|
was := routerAskOf
|
||||||
wasKept := keptDeliveryMerge
|
wasKept := keptDeliveryMerge
|
||||||
t.Cleanup(func() { routerAskOf, keptDeliveryMerge = was, wasKept })
|
t.Cleanup(func() { routerAskOf, keptDeliveryMerge = was, wasKept })
|
||||||
a := askedBy(t, "", theDelivery)
|
a := askedBy(t, sdkdelivery.TellerModule, sdkdelivery.TellerModule)
|
||||||
keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) {
|
keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) {
|
||||||
return a.merge, true, nil
|
return a.merge, true, nil
|
||||||
}
|
}
|
||||||
@@ -71,8 +72,8 @@ func TestAMergeNamingNoAskerIsMovedOnMeshDeliverysWarrant(t *testing.T) {
|
|||||||
if _, err := approvedAnnounced(t.Context(), nil, "novox", "mesh-controller", announcedPull(7, a.merge.HeadSHA)); err != nil {
|
if _, err := approvedAnnounced(t.Context(), nil, "novox", "mesh-controller", announcedPull(7, a.merge.HeadSHA)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if readUnder != theDelivery {
|
if readUnder != sdkdelivery.TellerModule {
|
||||||
t.Errorf("with no asker named the warrant was read under %q, not %q", readUnder, theDelivery)
|
t.Errorf("the warrant was read under %q, not %q", readUnder, sdkdelivery.TellerModule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ func TestAModuleBuiltFromANewRepositoryIsRegisteredOnlyOnTheOperatorsApproveOnTh
|
|||||||
a.merge.Owner, a.merge.Repo, a.merge.Number = "novox", "mesh-host", 7
|
a.merge.Owner, a.merge.Repo, a.merge.Number = "novox", "mesh-host", 7
|
||||||
if c.answer {
|
if c.answer {
|
||||||
record, _ := json.Marshal(map[string]any{"state": "closed", "ask": a.ask, "warrant": a.warrant})
|
record, _ := json.Marshal(map[string]any{"state": "closed", "ask": a.ask, "warrant": a.warrant})
|
||||||
if _, err := records.Put(ctx, theDelivery+"."+a.merge.AskID, record); err != nil {
|
if _, err := records.Put(ctx, sdkdelivery.TellerModule+"."+a.merge.AskID, record); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,7 +22,7 @@ func approvedMerge(t *testing.T) anApprovedMerge {
|
|||||||
heads := map[string]string{"novox/mesh-controller@aaaaaaaaaaaa": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
heads := map[string]string{"novox/mesh-controller@aaaaaaaaaaaa": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
"novox/mesh-catalog@bbbbbbbbbbbb": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}
|
"novox/mesh-catalog@bbbbbbbbbbbb": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}
|
||||||
m := deliveryMerge{Owner: "novox", Repo: "mesh-controller", Number: 7, HeadSHA: "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
m := deliveryMerge{Owner: "novox", Repo: "mesh-controller", Number: 7, HeadSHA: "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||||
Group: "g1", AskID: "merge-g1", Option: optionApprove, Heads: heads}
|
Group: "g1", AskID: "merge-g1", Option: optionApprove, Heads: heads, Asker: sdkdelivery.TellerModule}
|
||||||
bind := func(option string, h map[string]string) string {
|
bind := func(option string, h map[string]string) string {
|
||||||
d, err := asks.ActDigest(mergeAct(m.AskID, option, h))
|
d, err := asks.ActDigest(mergeAct(m.AskID, option, h))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,7 +36,7 @@ func approvedMerge(t *testing.T) anApprovedMerge {
|
|||||||
Options: []asks.Option{
|
Options: []asks.Option{
|
||||||
{ID: optionApprove, Label: "Approve", Does: "merges the group", Level: asks.Approve, Binds: bind(optionApprove, heads)},
|
{ID: optionApprove, Label: "Approve", Does: "merges the group", Level: asks.Approve, Binds: bind(optionApprove, heads)},
|
||||||
{ID: "decline", Label: "Decline", Does: "leaves it", Level: asks.Approve, Binds: bind("decline", heads)}}}
|
{ID: "decline", Label: "Decline", Does: "leaves it", Level: asks.Approve, Binds: bind("decline", heads)}}}
|
||||||
w := asks.Warrant{Ask: m.AskID, Asker: theDelivery, Outcome: asks.OutcomeChosen, Option: optionApprove, Label: "Approve",
|
w := asks.Warrant{Ask: m.AskID, Asker: sdkdelivery.TellerModule, Outcome: asks.OutcomeChosen, Option: optionApprove, Label: "Approve",
|
||||||
Level: asks.Approve, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "1", Verified: "user id"},
|
Level: asks.Approve, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "1", Verified: "user id"},
|
||||||
At: at, AskDigest: ask.Digest()}
|
At: at, AskDigest: ask.Digest()}
|
||||||
return anApprovedMerge{merge: m, ask: ask, warrant: w}
|
return anApprovedMerge{merge: m, ask: ask, warrant: w}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0505 §5: a delivery whose cure is its author's new head reaches the operator in words that name its
|
||||||
|
// author and say a new head is needed, offer no Stop and never call it the operator's; the line as mesh-delivery-gate
|
||||||
|
// says it (ready.go readyLines: needs "new-head", author, branch).
|
||||||
|
func TestADeliveryThatNeedsANewHeadNamesItsAuthorAndOffersNoStop(t *testing.T) {
|
||||||
|
l := stalledLine{ID: "novox/mesh-controller@499ff5c21861", Number: 432, State: "rejected", For: "2h1m0s", Bound: "2h0m0s",
|
||||||
|
H2: "none: a new head from its author, or a recheck that passes", Needs: needsNewHead,
|
||||||
|
Author: "the author of feat/quiet-deliveries (quiet-deliveries on shanks)", Branch: "feat/quiet-deliveries",
|
||||||
|
Says: "novox/mesh-controller#432 passed its checks at 499ff5c21861 and is not merged: a conflict"}
|
||||||
|
o := stalledObservations([]stalledLine{l})[0]
|
||||||
|
for _, words := range []string{o.Headline, o.Explanation, o.Resolved, o.Needs} {
|
||||||
|
if why, ok := conditions.Plain(words); !ok {
|
||||||
|
t.Errorf("%q is not plain: %s", words, why)
|
||||||
|
}
|
||||||
|
for _, never := range []string{"stop it", "operator's"} {
|
||||||
|
if strings.Contains(words, never) {
|
||||||
|
t.Errorf("%q says %q", words, never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.Headline != "Pull request 432 of mesh-controller needs a new head from its author" ||
|
||||||
|
!strings.Contains(o.Explanation, "until the author of feat/quiet-deliveries (quiet-deliveries on shanks) pushes a new head") ||
|
||||||
|
!strings.Contains(o.Explanation, "for 2 hours") || len(o.Actions) != 0 ||
|
||||||
|
o.Needs != "ask the author of feat/quiet-deliveries (quiet-deliveries on shanks) for a new head." {
|
||||||
|
t.Fatalf("words: %q / %q / %q / %v", o.Headline, o.Explanation, o.Needs, o.Actions)
|
||||||
|
}
|
||||||
|
// A branch in code is not said; the author is then "its author".
|
||||||
|
l.Author = "the author of fix/a1b2c3d4e5f6"
|
||||||
|
if o := stalledObservations([]stalledLine{l})[0]; !strings.Contains(o.Needs, "ask its author") {
|
||||||
|
t.Fatalf("a dotted branch was said: %q", o.Needs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ func TestANewModuleIsRegisteredOnTheOperatorsApproveOfItsMergeAndRefusedOtherwis
|
|||||||
if c.built != nil {
|
if c.built != nil {
|
||||||
src = *c.built
|
src = *c.built
|
||||||
}
|
}
|
||||||
id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", src, "build-1", "", "main")
|
id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", src, byBuild("build-1"), "", "main")
|
||||||
switch {
|
switch {
|
||||||
case c.accepted && err != nil:
|
case c.accepted && err != nil:
|
||||||
t.Fatalf("%s: the Approved merge's new module was refused: %v", c.ac, err)
|
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"}}}
|
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" }}
|
forms := &sourceForms{bases: map[string]string{}, base: func(string) string { return "http://forge.internal:20000" }}
|
||||||
built := inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"}
|
built := inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"}
|
||||||
if id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-watchman", built, "build-2", "", "main"); err != nil || id == 0 {
|
if id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-watchman", built, byBuild("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)
|
t.Fatalf("1146/AC5: a second module from a repository the catalogue builds from was refused (id %d): %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1617/AC2 (unit): a new module whose first build a merge's hearing asked is not asked again when its batch is cut,
|
|
||||||
// and one whose ask failed, was never handed over or is long lost is.
|
|
||||||
func TestAModuleWhoseBuildIsComingIsNotAskedAgain(t *testing.T) {
|
|
||||||
now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
|
||||||
source := buildSource{Repository: "http://forge/novox/mesh-catalog.git", Seat: "git"}
|
|
||||||
asked := func(path string, mod func(*inventory.RequestOutcome)) inventory.RequestOutcome {
|
|
||||||
r := inventory.RequestOutcome{BuildRequest: inventory.BuildRequest{ID: "b-" + path, Repository: source.Repository,
|
|
||||||
Seat: "git", Path: path, For: "merge", At: now.Add(-3 * time.Minute)}}
|
|
||||||
if mod != nil {
|
|
||||||
mod(&r)
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
requests := []inventory.RequestOutcome{
|
|
||||||
asked("modules/go", nil),
|
|
||||||
asked("modules/failed", func(r *inventory.RequestOutcome) { r.Heard, r.Failed = true, "no module.json" }),
|
|
||||||
asked("modules/notasked", func(r *inventory.RequestOutcome) { r.NotAsked = "the bus is silent" }),
|
|
||||||
asked("modules/lost", func(r *inventory.RequestOutcome) { r.At = now.Add(-2 * time.Hour) }),
|
|
||||||
asked("modules/byhand", func(r *inventory.RequestOutcome) { r.For = "build" }),
|
|
||||||
}
|
|
||||||
got := unaskedDirs([]string{"modules/go", "modules/failed", "modules/notasked", "modules/lost", "modules/byhand",
|
|
||||||
"modules/nodejs"}, requests, source, now)
|
|
||||||
want := []string{"modules/failed", "modules/notasked", "modules/lost", "modules/byhand", "modules/nodejs"}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("asked again %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ func TestANewModuleFromANewRepositoryIsRegisteredOnlyOnTheOperatorsApproveOnTheB
|
|||||||
map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"})
|
map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"})
|
||||||
if c.answer {
|
if c.answer {
|
||||||
record, _ := json.Marshal(map[string]any{"state": "closed", "ask": a.ask, "warrant": a.warrant})
|
record, _ := json.Marshal(map[string]any{"state": "closed", "ask": a.ask, "warrant": a.warrant})
|
||||||
if _, err := records.Put(ctx, theDelivery+"."+a.merge.AskID, record); err != nil {
|
if _, err := records.Put(ctx, sdkdelivery.TellerModule+"."+a.merge.AskID, record); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,134 +4,110 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
)
|
)
|
||||||
|
|
||||||
// asksWithPaths records every build a merge asks for, as repository, path and ref. The merge window's goroutine and
|
// ADR 0501 §6 (issue 1851, A5): the live case of 2026-10-07 (novox/hq issue 300), on keys. A merge of the catalogue adds
|
||||||
// the test's own may ask at once, so the record is written under a lock (issue 1543).
|
// one module's directory and touches nothing else the mesh holds: nothing asks its first build, it opens no plan, and
|
||||||
func asksWithPaths(t *testing.T) *[][3]string {
|
// the module is registered from the builder's first done key of it, assigned nowhere, with no build request written.
|
||||||
t.Helper()
|
func TestAMergeAddingAModuleEndsRegisteredFromItsKeyAndAsksNoBuild(t *testing.T) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// The live case of 2026-10-07 (novox/hq issue 300), replayed: a merge of the catalogue adds one module's
|
|
||||||
// directory and touches nothing else the mesh holds. Its delivery plan said "builds new: …, sent nowhere";
|
|
||||||
// the merge said it changed nothing any module the mesh holds is built from, and built nothing, so the
|
|
||||||
// module was never registered and `assign` refused it. The merge asks for its build, at the branch merged
|
|
||||||
// into, from the repository as the mesh spells it — and opens no plan, since nothing it holds moved.
|
|
||||||
func TestAMergeBuildsTheNewModuleItAdds(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
asked := asksWithPaths(t)
|
noBuildAsked(t)
|
||||||
|
b := aBuilderOnTheBus(t)
|
||||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "networkmanager", Version: "1"},
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "networkmanager", Version: "1"},
|
||||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/networkmanager", Ref: "main",
|
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/networkmanager", Ref: "main",
|
||||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "3da80a4b00",
|
watching, stop := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(stop)
|
||||||
|
watchNewModules(watching, open)
|
||||||
|
|
||||||
|
const commit = "3da80a4b00000000"
|
||||||
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit,
|
||||||
Paths: []string{"modules/systemd-resolved/module.json", "modules/systemd-resolved/cmd/main.go"},
|
Paths: []string{"modules/systemd-resolved/module.json", "modules/systemd-resolved/cmd/main.go"},
|
||||||
ModuleDirs: []string{"modules/systemd-resolved"}, ModuleDirsSaid: true}
|
ModuleDirs: []string{"modules/systemd-resolved"}, ModuleDirsSaid: true}
|
||||||
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
|
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
want := [3]string{"novox/mesh-catalog", "modules/systemd-resolved", "main"}
|
|
||||||
if len(*asked) != 1 || (*asked)[0] != want {
|
|
||||||
t.Fatalf("the merge adding modules/systemd-resolved asked for %v, not %v", *asked, want)
|
|
||||||
}
|
|
||||||
if plans, err := inv.OpenPlans(ctx); err != nil || len(plans) != 0 {
|
if plans, err := inv.OpenPlans(ctx); err != nil || len(plans) != 0 {
|
||||||
t.Fatalf("a merge moving nothing the mesh holds opened a plan: %+v %v", plans, err)
|
t.Fatalf("a merge moving nothing the mesh holds opened a plan: %+v %v", plans, err)
|
||||||
}
|
}
|
||||||
|
// The indexer writes the module's trunk value; the builder builds it, then its key is done.
|
||||||
|
fp := fpOf('7')
|
||||||
|
manifest := b.indexed(t, "systemd-resolved", "1", "novox/mesh-catalog", "modules/systemd-resolved", commit, fp)
|
||||||
|
b.built(t, "systemd-resolved", "1", fp, manifest, sdkdelivery.BuildBuilding)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
if _, known := inventoryHolds(t, inv, "systemd-resolved"); known {
|
||||||
|
t.Fatal("0501 §1: registered from a key still building")
|
||||||
|
}
|
||||||
|
if obs := newModuleObservations(time.Now().Add(time.Hour)); len(obs) != 1 || obs[0].Kind != kindNewModuleWaits {
|
||||||
|
t.Fatalf("0501 §6: the new module waiting on its key past its bound is named by %+v", obs)
|
||||||
|
}
|
||||||
|
b.built(t, "systemd-resolved", "1", fp, manifest, sdkdelivery.BuildDone)
|
||||||
|
becomes(t, 10*time.Second, "the new module registered", func() bool {
|
||||||
|
_, known := inventoryHolds(t, inv, "systemd-resolved")
|
||||||
|
return known
|
||||||
|
})
|
||||||
|
// The take-in forgets the wait just after the registration it made is seen.
|
||||||
|
becomes(t, 5*time.Second, "the registered module no longer named waiting", func() bool {
|
||||||
|
return len(newModuleObservations(time.Now().Add(time.Hour))) == 0
|
||||||
|
})
|
||||||
|
requests, err := inv.RequestsNamed(ctx, inventory.BuildRequest{Repository: "novox/mesh-catalog",
|
||||||
|
Path: "modules/systemd-resolved"}.Name())
|
||||||
|
if err != nil || len(requests) != 0 {
|
||||||
|
t.Fatalf("0501 §6: a build request was written: %+v (%v)", requests, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A merge that changes a held module and adds a new one does both: the held one walks its plan, the new
|
// ADR 0501 §7: a new module whose manifest uses a word this controller does not read is named waiting at once, never
|
||||||
// one is asked for beside it. A merge adding nothing builds nothing new.
|
// failed, and registered from the same key, with no second build, when a controller that reads it looks.
|
||||||
func TestAMergeBuildsItsNewModuleBesideItsPlan(t *testing.T) {
|
func TestANewModuleWithAnUnreadWordWaitsForTheControllerThatReadsIt(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
asked := asksWithPaths(t)
|
noBuildAsked(t)
|
||||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"},
|
b := aBuilderOnTheBus(t)
|
||||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", Ref: "main",
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "networkmanager", Version: "1"},
|
||||||
|
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/networkmanager", Ref: "main",
|
||||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c1aaaaaaaa",
|
fp := fpOf('8')
|
||||||
Paths: []string{"modules/app/index.ts", "modules/fresh/module.json"},
|
raw := `{"module":"watchman","version":"1","not-a-word-yet":"5m","build":{"artifacts":[{"name":"a","kind":"archive","from":"files"}]},` +
|
||||||
ModuleDirs: []string{"modules/app", "modules/fresh"}, ModuleDirsSaid: true}
|
`"resources":[{"id":"a","type":"archive","path":"/opt/watchman","artifact":"a"}]}`
|
||||||
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
|
digest := sdkdelivery.ManifestDigest([]byte(raw))
|
||||||
|
value, _ := sdkdelivery.EncodeManifest(sdkdelivery.Manifest{Module: "watchman", JSON: raw})
|
||||||
|
if _, err := b.manifests.Put(ctx, digest, value); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
paths := map[string]bool{}
|
trunk, _ := sdkdelivery.EncodeModule(sdkdelivery.Module{FP: "src1:" + fp[5:], FP2: fp, Manifest: digest,
|
||||||
for _, a := range *asked {
|
Repository: "novox/mesh-catalog", Commit: "4444444400000000", Path: "modules/watchman"})
|
||||||
paths[a[1]] = true
|
if _, err := b.modules.Put(ctx, "watchman", trunk); err != nil {
|
||||||
}
|
|
||||||
if !paths["modules/fresh"] || !paths["modules/app"] || len(*asked) != 2 {
|
|
||||||
t.Fatalf("asked %v; want the new module and the plan's first tier", *asked)
|
|
||||||
}
|
|
||||||
plans, err := inv.OpenPlans(ctx)
|
|
||||||
if err != nil || len(plans) != 1 || plans[0].Modules["fresh"] != nil || plans[0].Modules["app"] == nil {
|
|
||||||
t.Fatalf("the plan should walk app alone: %+v %v", plans, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
*asked = nil
|
|
||||||
m2 := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c2aaaaaaaa",
|
|
||||||
Paths: []string{"README.md"}, ModuleDirsSaid: true}
|
|
||||||
if err := (following{open: open}).SourceMoved(ctx, m2); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(*asked) != 0 {
|
b.built(t, "watchman", "1", fp, digest, sdkdelivery.BuildDone)
|
||||||
t.Fatalf("a merge adding no module asked for %v", *asked)
|
takeNewModules(ctx, open, "watchman")
|
||||||
|
if _, known := inventoryHolds(t, inv, "watchman"); known {
|
||||||
|
t.Fatal("0501 §7: a manifest with an unread word was registered")
|
||||||
}
|
}
|
||||||
}
|
obs := newModuleObservations(time.Now())
|
||||||
|
if len(obs) != 1 || obs[0].Kind != kindNewModuleWaits || !strings.Contains(obs[0].Summary, `"not-a-word-yet"`) {
|
||||||
// 1617/AC1 (integration): a merge adding a module to a repository the mesh builds modules from, heard while a batch of
|
t.Fatalf("0501 §7: the module waiting for a reader of its word is named by %+v", obs)
|
||||||
// that repository is still assembling (its window running), asks the module's first build when it is heard, so the
|
|
||||||
// module is registered while the batch waits; cutting the batch asks it no second time (1617/AC2).
|
|
||||||
func TestAMergeAddingAModuleAsksItsBuildBeforeItsBatchIsCut(t *testing.T) {
|
|
||||||
open := windowed(t)
|
|
||||||
groupsWaiting(t, "pr/c1")
|
|
||||||
asked := asksWithPaths(t)
|
|
||||||
catalog := func(commit string, made time.Time, paths ...string) link.SourceMoved {
|
|
||||||
return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit,
|
|
||||||
Head: "pr/" + commit, MergedAt: made.Format(time.RFC3339Nano), Paths: paths}
|
|
||||||
}
|
}
|
||||||
hear(t, open, catalog("c1", t0, "modules/app/index.ts"), t0)
|
builds, _ := inv.Builds(ctx, "watchman", 5)
|
||||||
if len(*asked) != 0 {
|
for _, b := range builds {
|
||||||
t.Fatalf("a merge adding no module asked %v", *asked)
|
if !b.Worked() {
|
||||||
}
|
t.Fatalf("0501 §7: a failed build was recorded: %+v", b)
|
||||||
hear(t, open, lone(catalog("c2", t0.Add(10*time.Second), "modules/go/packages.json", "modules/go/module.json")), t0.Add(10*time.Second))
|
|
||||||
if ws, bs := walks(t, open); len(ws) != 0 || len(bs) != 1 {
|
|
||||||
t.Fatalf("the batch should still be assembling: %d walk(s), %d batch(es)", len(ws), len(bs))
|
|
||||||
}
|
|
||||||
want := [3]string{"novox/mesh-catalog", "modules/go", "main"}
|
|
||||||
if len(*asked) != 1 || (*asked)[0] != want {
|
|
||||||
t.Fatalf("heard with its batch assembling, the merge asked %v, not %v", *asked, want)
|
|
||||||
}
|
|
||||||
cutAt(t, open, t0.Add(10*time.Second+90*time.Second))
|
|
||||||
n := 0
|
|
||||||
for _, a := range *asked {
|
|
||||||
if a[1] == "modules/go" {
|
|
||||||
n++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n != 1 {
|
|
||||||
t.Fatalf("modules/go was asked %d times: %v", n, *asked)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
|
"github.com/novox/mesh-controller/internal/builderkeys"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A new module is registered from its first done key (novox/hq ADR 0501 §1, §6; issue 1851, A5).
|
||||||
|
//
|
||||||
|
// Nothing asks a new module's first build: the builder builds every fingerprint the indexer writes, a new module's
|
||||||
|
// included (ADR 0429 §5). The controller registers a module the catalogue does not hold from the builder's done key of the
|
||||||
|
// fingerprint the indexer's trunk value names, under the same rules of who may register as before (ADR 0501 §3): from a
|
||||||
|
// repository the catalogue builds another module from, or on the operator's Approve of the merge the trunk value names
|
||||||
|
// (a first merge, kept with mesh-delivery's word), or at the terminal (ADR 0501 §4). It is assigned nowhere.
|
||||||
|
//
|
||||||
|
// **No timer** (ADR 0426): a write of the builder or the indexer wakes it for that module (builderkeys.Watch), a first
|
||||||
|
// merge's word wakes it for that repository, and a controller that starts looks at every module once (ADR 0501 §7: a
|
||||||
|
// manifest the controller before could not read is registered when the controller that reads it runs).
|
||||||
|
|
||||||
|
// newModulesMu keeps one take-in of new modules at a time in this process.
|
||||||
|
var newModulesMu sync.Mutex
|
||||||
|
|
||||||
|
// newModuleWait is a new module the controller could not register yet, and why: what the first-builds condition names.
|
||||||
|
type newModuleWait struct {
|
||||||
|
Module, Repository, Commit, Why string
|
||||||
|
// Word is the manifest word this controller does not read, when that is why (ADR 0501 §7).
|
||||||
|
Word string
|
||||||
|
Since time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newModulesWaiting are the new modules waiting, by module, in this process: a restart looks at every module again.
|
||||||
|
var newModulesWaiting = struct {
|
||||||
|
sync.Mutex
|
||||||
|
by map[string]newModuleWait
|
||||||
|
}{by: map[string]newModuleWait{}}
|
||||||
|
|
||||||
|
// takeNewModules looks at the modules the indexer holds that the catalogue does not, all of them or those named, and
|
||||||
|
// registers each whose done key may register it. Each outcome is said once: a module still waiting for the same reason
|
||||||
|
// is not said again.
|
||||||
|
func takeNewModules(ctx context.Context, open *stores, only ...string) {
|
||||||
|
newModulesMu.Lock()
|
||||||
|
defer newModulesMu.Unlock()
|
||||||
|
keys, closeKeys, err := openBuilderKeys()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("new modules: the builder's keys cannot be read: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer closeKeys()
|
||||||
|
for _, module := range takeNewModulesWith(ctx, open.inventory, keys, only...) {
|
||||||
|
// An assignment waiting for it is made now (ADR 0501 §6; issue 1851, A10).
|
||||||
|
for _, line := range settlePendingOnKey(ctx, open, module) {
|
||||||
|
fmt.Println(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeNewModulesWith is takeNewModules on the buckets handed in; it answers the modules it registered.
|
||||||
|
func takeNewModulesWith(ctx context.Context, inv *inventory.Inventory, keys newModuleReads, only ...string) []string {
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("new modules: the catalogue cannot be read: %v\n", err)
|
||||||
|
// empty-on-error: said above; nothing is registered this time, and the next write of a key looks again
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
held := map[string]bool{}
|
||||||
|
for _, e := range entries {
|
||||||
|
held[e.Manifest.Module] = true
|
||||||
|
}
|
||||||
|
values, unread, err := keys.Modules(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("new modules: the indexer's modules cannot be read: %v\n", err)
|
||||||
|
// empty-on-error: said above; nothing is registered this time, and the next write of a key looks again
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for module, err := range unread {
|
||||||
|
if !held[module] && (len(only) == 0 || slices.Contains(only, module)) {
|
||||||
|
sayNewModuleWait(newModuleWait{Module: module, Why: "the indexer's value of it cannot be read: " + err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(values))
|
||||||
|
for module := range values {
|
||||||
|
if !held[module] && (len(only) == 0 || slices.Contains(only, module)) {
|
||||||
|
names = append(names, module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
var registered []string
|
||||||
|
for _, module := range names {
|
||||||
|
v := values[module]
|
||||||
|
_, _, err := takeInKey(ctx, inv, keys, keyTake{Module: module, Trunk: v, Ask: registrationAsk{Merge: v.Commit}})
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
forgetNewModuleWait(module)
|
||||||
|
registered = append(registered, module)
|
||||||
|
fmt.Printf("%s is new in %s at %.8s: registered from the builder's done key, assigned nowhere\n", module,
|
||||||
|
v.Repository, v.Commit)
|
||||||
|
case errors.Is(err, errKeyWaits):
|
||||||
|
w := newModuleWait{Module: module, Repository: v.Repository, Commit: v.Commit, Why: err.Error()}
|
||||||
|
if word := wordIn(err.Error()); word != "" {
|
||||||
|
w.Word = word
|
||||||
|
}
|
||||||
|
sayNewModuleWait(w)
|
||||||
|
default:
|
||||||
|
sayNewModuleWait(newModuleWait{Module: module, Repository: v.Repository, Commit: v.Commit,
|
||||||
|
Why: "not registered: " + err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return registered
|
||||||
|
}
|
||||||
|
|
||||||
|
// newModuleReads is what the take-in of new modules reads of the builder and the indexer.
|
||||||
|
type newModuleReads interface {
|
||||||
|
keyReads
|
||||||
|
Modules(ctx context.Context) (map[string]sdkdelivery.Module, map[string]error, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ newModuleReads = builderkeys.Reader{}
|
||||||
|
|
||||||
|
// wordIn is the manifest word a take-in's words say this controller does not read.
|
||||||
|
func wordIn(said string) string {
|
||||||
|
_, rest, ok := strings.Cut(said, "uses the word \"")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
word, _, _ := strings.Cut(rest, "\"")
|
||||||
|
return word
|
||||||
|
}
|
||||||
|
|
||||||
|
// sayNewModuleWait keeps a new module waiting and says it, once for each reason.
|
||||||
|
func sayNewModuleWait(w newModuleWait) {
|
||||||
|
newModulesWaiting.Lock()
|
||||||
|
defer newModulesWaiting.Unlock()
|
||||||
|
was, known := newModulesWaiting.by[w.Module]
|
||||||
|
if known && was.Why == w.Why && was.Commit == w.Commit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Since = time.Now().UTC()
|
||||||
|
if known && was.Commit == w.Commit {
|
||||||
|
w.Since = was.Since
|
||||||
|
}
|
||||||
|
newModulesWaiting.by[w.Module] = w
|
||||||
|
fmt.Printf("%s is new at %.8s and not registered yet: %s\n", w.Module, w.Commit, firstLine(w.Why))
|
||||||
|
}
|
||||||
|
|
||||||
|
func forgetNewModuleWait(module string) {
|
||||||
|
newModulesWaiting.Lock()
|
||||||
|
defer newModulesWaiting.Unlock()
|
||||||
|
delete(newModulesWaiting.by, module)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindNewModuleWaits is what a new module waiting for this controller's reader of its manifest, or waiting past its
|
||||||
|
// bound, raises (ADR 0501 §6, §7: the first-builds condition reads the key, not a build request).
|
||||||
|
const kindNewModuleWaits = "new-module-waits"
|
||||||
|
|
||||||
|
// newModuleWaitBound is how long a new module waits for its key before it is said: the builder builds a fingerprint in
|
||||||
|
// minutes. Not a delay: its key's write registers it the moment it is done.
|
||||||
|
const newModuleWaitBound = 30 * time.Minute
|
||||||
|
|
||||||
|
// newModuleObservations names the new modules waiting: one whose manifest uses a word this controller does not read at
|
||||||
|
// once (nothing is lost, but the operator sees what waits for which controller), any other past the bound.
|
||||||
|
func newModuleObservations(now time.Time) []conditions.Observation {
|
||||||
|
newModulesWaiting.Lock()
|
||||||
|
defer newModulesWaiting.Unlock()
|
||||||
|
var out []conditions.Observation
|
||||||
|
for _, w := range newModulesWaiting.by {
|
||||||
|
if w.Word == "" && now.Sub(w.Since) <= newModuleWaitBound {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
head := fmt.Sprintf("%s is not registered: %s", w.Module, firstLine(w.Why))
|
||||||
|
resolved := "its key is done and its take-in registers it"
|
||||||
|
if w.Word != "" {
|
||||||
|
head = fmt.Sprintf("%s waits for a controller that reads the manifest word %q", w.Module, w.Word)
|
||||||
|
resolved = "a controller that reads the word runs and registers it from the same key, with no second build"
|
||||||
|
}
|
||||||
|
out = append(out, conditions.Observation{Scope: conditions.ScopeMerge,
|
||||||
|
ID: fmt.Sprintf("%s_%s", w.Module, short(w.Commit)), Kind: kindNewModuleWaits, Severity: conditions.Warning,
|
||||||
|
Summary: head,
|
||||||
|
Said: fmt.Sprintf("%s at %s waits since %s: %s", w.Module, w.Commit, w.Since.Format(time.RFC3339), w.Why),
|
||||||
|
Headline: fmt.Sprintf("The new module %s is not registered yet", w.Module),
|
||||||
|
Explanation: fmt.Sprintf("The merge %.8s of %s adds %s. The mesh registers it from the builder's done key of its "+
|
||||||
|
"fingerprint, and has not yet: %s", w.Commit, w.Repository, w.Module, w.Why),
|
||||||
|
Resolved: resolved,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchNewModules wakes the take-in of new modules for each module the builder or the indexer writes, until ctx ends, and
|
||||||
|
// looks at every module once now. A bucket not there yet is said: the look at start and a first merge's word still work.
|
||||||
|
func watchNewModules(ctx context.Context, open *stores) {
|
||||||
|
inBackground(func() {
|
||||||
|
answerHeldMerges(ctx, open.inventory)
|
||||||
|
takeNewModules(ctx, open)
|
||||||
|
})
|
||||||
|
keys, closeKeys, err := openBuilderKeys()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("new modules: the builder's keys are not watched: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := builderkeys.Watch(ctx, keys.JS, func(module string) { wakeNewModule(ctx, open, module) }); err != nil {
|
||||||
|
closeKeys()
|
||||||
|
fmt.Printf("new modules: the builder's keys are not watched: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
closeKeys()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wakeNewModule takes module in when the catalogue does not hold it: never on the watch's own goroutine, which must keep
|
||||||
|
// draining.
|
||||||
|
func wakeNewModule(ctx context.Context, open *stores, module string) {
|
||||||
|
inBackground(func() {
|
||||||
|
entries, err := open.inventory.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Manifest.Module == module {
|
||||||
|
// Known: a plan waiting on its key is woken by the plans' own look.
|
||||||
|
askToLook()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
takeNewModules(ctx, open, module)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// background is the work the take-ins of keys and of announcements start beside the event that woke them, so a test
|
||||||
|
// 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()) {
|
||||||
|
background.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer background.Done()
|
||||||
|
work()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newModulesOf are the modules the indexer holds in a repository: what a first merge's word wakes.
|
||||||
|
func newModulesOf(ctx context.Context, repository string) ([]string, error) {
|
||||||
|
keys, closeKeys, err := openBuilderKeys()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer closeKeys()
|
||||||
|
values, _, err := keys.Modules(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for module, v := range values {
|
||||||
|
if strings.EqualFold(v.Repository, repository) {
|
||||||
|
out = append(out, module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ADR 0501 §6, §7 (issue 1851, A5b): the condition of a new module reads its key's take-in: one waiting for a controller
|
||||||
|
// that reads its manifest's word is named at once, one waiting for its key only past the bound, each reason said once.
|
||||||
|
func TestANewModuleWaitingIsNamedByWhatItWaitsFor(t *testing.T) {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newModulesWaiting.Lock()
|
||||||
|
newModulesWaiting.by = map[string]newModuleWait{}
|
||||||
|
newModulesWaiting.Unlock()
|
||||||
|
})
|
||||||
|
word := `waits: watchman's manifest uses the word "key-ttl", which this controller does not read: it is registered when a controller that reads it runs`
|
||||||
|
sayNewModuleWait(newModuleWait{Module: "watchman", Commit: "c1", Why: word, Word: wordIn(word)})
|
||||||
|
sayNewModuleWait(newModuleWait{Module: "view", Commit: "c1", Why: "waits: the build of view at aaaa is building on ace"})
|
||||||
|
now := time.Now()
|
||||||
|
obs := newModuleObservations(now)
|
||||||
|
if len(obs) != 1 || !strings.Contains(obs[0].Summary, `"key-ttl"`) || obs[0].Kind != kindNewModuleWaits {
|
||||||
|
t.Fatalf("at once, only the unread word is named: %+v", obs)
|
||||||
|
}
|
||||||
|
if obs := newModuleObservations(now.Add(newModuleWaitBound + time.Minute)); len(obs) != 2 {
|
||||||
|
t.Fatalf("past the bound, both are named: %+v", obs)
|
||||||
|
}
|
||||||
|
forgetNewModuleWait("watchman")
|
||||||
|
forgetNewModuleWait("view")
|
||||||
|
if obs := newModuleObservations(now.Add(time.Hour)); len(obs) != 0 {
|
||||||
|
t.Fatalf("registered modules are still named: %+v", obs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/chain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1146: a merge's ask that failed is kept under an id the bus takes as a key, for a module at the repository's root
|
|
||||||
// (".") as for one in a folder.
|
|
||||||
func TestTheIdOfABuildNotAskedIsAKeyTheBusTakes(t *testing.T) {
|
|
||||||
for dir, want := range map[string]string{
|
|
||||||
".": "not-asked-0177ea7a",
|
|
||||||
"modules/mesh-delivery-view": "not-asked-0177ea7a-modules-mesh-delivery-view",
|
|
||||||
} {
|
|
||||||
id := notAskedID("0177ea7a00000000", dir)
|
|
||||||
if id != want {
|
|
||||||
t.Errorf("notAskedID(%q) = %q, want %q", dir, id, want)
|
|
||||||
}
|
|
||||||
if !chain.ValidKey("req." + id) {
|
|
||||||
t.Errorf("notAskedID(%q) = %q: the bus takes no key req.%s", dir, id, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -109,8 +109,8 @@ func TestAFeatureWithANewSeatVerbIsOneBatchAndOnePlanInOrderWithNoAfterLine(t *t
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Issue 1049: a move delivered half before its second merge. The controller adds a module the catalogue removes, in one
|
// Issue 1049: a move delivered half before its second merge. The controller adds a module the catalogue removes, in one
|
||||||
// group: the merges are one batch and one plan in either order, the module is built once and from the controller's
|
// group: the merges are one batch and one plan in either order, no build of the module is asked (it is registered from
|
||||||
// repository, and the catalogue's removal does not forget it.
|
// the builder's key, ADR 0501 §6), and the catalogue's removal does not forget it.
|
||||||
func TestAMoveOfAModuleAcrossTwoRepositoriesIsOnePlanAndBuildsItOnceFromTheNewOne(t *testing.T) {
|
func TestAMoveOfAModuleAcrossTwoRepositoriesIsOnePlanAndBuildsItOnceFromTheNewOne(t *testing.T) {
|
||||||
for name, controllerFirst := range map[string]bool{"controller_first": true, "catalogue_first": false} {
|
for name, controllerFirst := range map[string]bool{"controller_first": true, "catalogue_first": false} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
@@ -149,17 +149,12 @@ func TestAMoveOfAModuleAcrossTwoRepositoriesIsOnePlanAndBuildsItOnceFromTheNewOn
|
|||||||
if len(ws) != 1 || len(bs) != 0 {
|
if len(ws) != 1 || len(bs) != 0 {
|
||||||
t.Fatalf("a move: %d walk(s), %d batch(es); want exactly one plan", len(ws), len(bs))
|
t.Fatalf("a move: %d walk(s), %d batch(es); want exactly one plan", len(ws), len(bs))
|
||||||
}
|
}
|
||||||
var fromController int
|
// **No build of it is asked from either repository** (ADR 0501 §6, issue 1851): the move is registered from the
|
||||||
|
// builder's done key of route-proxy in the controller's repository, on the operator's Approve of the group.
|
||||||
for _, a := range *asked {
|
for _, a := range *asked {
|
||||||
if strings.Contains(a[0], "mesh-controller") && a[1] == "modules/route-proxy" {
|
if a[1] == "modules/route-proxy" {
|
||||||
fromController++
|
t.Errorf("a build of route-proxy was asked: %v", a)
|
||||||
}
|
}
|
||||||
if strings.Contains(a[0], "mesh-catalog") && a[1] == "modules/route-proxy" {
|
|
||||||
t.Errorf("route-proxy was built from the repository it left: %v", a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fromController != 1 {
|
|
||||||
t.Fatalf("route-proxy was built %d time(s) from the controller's repository: %v", fromController, *asked)
|
|
||||||
}
|
}
|
||||||
entries, err := open.inventory.Catalogued(ctx)
|
entries, err := open.inventory.Catalogued(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -28,48 +28,6 @@ func stoodOn(edges map[string][]string) map[string][]string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// A module built before the module it stands on is built against the old one and reports success
|
|
||||||
// (novox/hq 04-ISSUES/131). So bases come first, however the set arrived.
|
|
||||||
func TestBasesAreBuiltBeforeWhatStandsOnThem(t *testing.T) {
|
|
||||||
in := []inventory.Entry{entry("app"), entry("runtime"), entry("other"), entry("base")}
|
|
||||||
edges := stoodOn(map[string][]string{"app": {"runtime"}, "runtime": {"base"}})
|
|
||||||
got := orderByBases(in, edges)
|
|
||||||
pos := map[string]int{}
|
|
||||||
for i, e := range got {
|
|
||||||
pos[e.Manifest.Module] = i
|
|
||||||
}
|
|
||||||
if !(pos["base"] < pos["runtime"] && pos["runtime"] < pos["app"]) {
|
|
||||||
t.Fatalf("bases not first: %v", pos)
|
|
||||||
}
|
|
||||||
if len(got) != 4 {
|
|
||||||
t.Fatalf("an entry was lost or doubled: %d", len(got))
|
|
||||||
}
|
|
||||||
// A base outside the set is not waited for: it is not being rebuilt.
|
|
||||||
got = orderByBases([]inventory.Entry{entry("app")}, stoodOn(map[string][]string{"app": {"elsewhere"}}))
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("a dependency outside the set changed the set: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module registered from its manifest and never built still names its bases there; once built,
|
|
||||||
// the recorded edge is what says so. Both are read, and a module never stands on itself.
|
|
||||||
func TestWhatStandsOnAModuleIsReadFromItsBuildOrItsManifest(t *testing.T) {
|
|
||||||
built := entry("gitea")
|
|
||||||
edges := stoodOn(map[string][]string{"gitea": {"mesh-tools"}})
|
|
||||||
if !standsOnModule(built, "mesh-tools", edges) {
|
|
||||||
t.Fatal("a recorded edge was not read")
|
|
||||||
}
|
|
||||||
if standsOnModule(built, "gitea", edges) || standsOnModule(built, "postgres", edges) {
|
|
||||||
t.Fatal("an edge was invented")
|
|
||||||
}
|
|
||||||
fresh := inventory.Entry{Manifest: catalogue.Manifest{Module: "plex", Build: &catalogue.Build{
|
|
||||||
On: []catalogue.BuildsOn{{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"}},
|
|
||||||
}}}
|
|
||||||
if !standsOnModule(fresh, "mesh-tools", nil) {
|
|
||||||
t.Fatal("a manifest's own base was not read")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A merge names a repository the way the forge does; a source is recorded the way a build was
|
// A merge names a repository the way the forge does; a source is recorded the way a build was
|
||||||
// asked for. The two meet on owner/repo and branch, whichever form the record took.
|
// asked for. The two meet on owner/repo and branch, whichever form the record took.
|
||||||
func TestAMergeMatchesTheSourcesBuiltFromIt(t *testing.T) {
|
func TestAMergeMatchesTheSourcesBuiltFromIt(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A new controller's own grants reach the bus before it writes (novox/hq issue 1800).
|
||||||
|
//
|
||||||
|
// A controller's grants are composed by the controller binary itself (broker.PermissionsFor), so the controller that
|
||||||
|
// walks a delivery cannot put its successor's grants in the bus's user list: the list that carries them was sent only
|
||||||
|
// once the new controller passed its gate on its first machine (carryUserList). Until then the new controller wrote
|
||||||
|
// buckets its account was not granted, and the bus refused them: plans-building on 2026-10-21, declared-packages on
|
||||||
|
// 2026-10-22, and R4e's states-announced would be the third.
|
||||||
|
//
|
||||||
|
// So a controller that starts, holding the lease and before it starts anything that writes its buckets, sends the
|
||||||
|
// machine holding the bus its user list when the list it composes is not the one that machine was last sent: the grants
|
||||||
|
// step's send (release.go grantsStep), every build there kept at the one it runs, refused unsent when the declaration
|
||||||
|
// would change more than the user list (errNotGrantsOnly). No build of a running plan reaches that machine before the
|
||||||
|
// plan's own tier sends it (ADR 0503 §8). A send that cannot be made is said once, and the controller serves on.
|
||||||
|
//
|
||||||
|
// It does not ask heldMachines, as carryUserList does: every reason that gives is a build that would move with the
|
||||||
|
// list (a policy, a plan not yet sent there, no gate passed), and here none can. The running plan that brought this
|
||||||
|
// controller holds the bus's machine until its tier sends it; asking would leave the list behind exactly when 1800
|
||||||
|
// meets it. The refusal unsent (errNotGrantsOnly) is the guard instead.
|
||||||
|
|
||||||
|
// ownGrantsCarrier is what the start's send reads and does: whether the bus's list is behind and on which machine, and
|
||||||
|
// the grants-only send to it. Variables so a test needs no store and no bus.
|
||||||
|
type ownGrantsCarrier struct {
|
||||||
|
behind func(ctx context.Context) (holder string, behind bool, err error)
|
||||||
|
send func(ctx context.Context, holder string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// carry sends the machine holding the bus the controller's own user list when it is behind, and says what it did.
|
||||||
|
func (c ownGrantsCarrier) carry(ctx context.Context) string {
|
||||||
|
holder, behind, err := c.behind(ctx)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Sprintf("whether the bus's user list carries this controller's grants cannot be read, so it is not sent "+
|
||||||
|
"at start; a bucket it newly writes may be refused until the gate carries it: %v", err)
|
||||||
|
case holder == "" || !behind:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := c.send(ctx, holder); err != nil {
|
||||||
|
return fmt.Sprintf("this controller's grants could not be sent to %s, which holds the bus, at its start; a bucket "+
|
||||||
|
"it newly writes may be refused until the gate carries them: %v", holder, err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("sent %s, which holds the bus, this controller's user list alone, every build there kept, "+
|
||||||
|
"before it writes", holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWithOwnGrants carries the controller's own grants to the bus and only then starts what writes (then).
|
||||||
|
func startWithOwnGrants(ctx context.Context, c ownGrantsCarrier, then func()) {
|
||||||
|
if said := c.carry(ctx); said != "" {
|
||||||
|
fmt.Println(said)
|
||||||
|
}
|
||||||
|
then()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownGrantsOf is the carrier of the serving controller: the grants step's reading and its grants-only send.
|
||||||
|
func ownGrantsOf(open *stores) ownGrantsCarrier {
|
||||||
|
return ownGrantsCarrier{
|
||||||
|
behind: func(ctx context.Context) (string, bool, error) { return brokerBehindOf(ctx, open, nil) },
|
||||||
|
send: func(ctx context.Context, holder string) error {
|
||||||
|
_, err := sendRollout(keepingEveryBuild(withScope(ctx, sendScope{}), holder), open, []string{holder})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1800 (1): a controller starting with the bus's list behind its own sends the bus's machine once, before anything
|
||||||
|
// that writes its buckets starts.
|
||||||
|
func TestAStartingControllerSendsItsGrantsBeforeItWrites(t *testing.T) {
|
||||||
|
var order []string
|
||||||
|
c := ownGrantsCarrier{
|
||||||
|
behind: func(context.Context) (string, bool, error) { return "novox", true, nil },
|
||||||
|
send: func(_ context.Context, holder string) error { order = append(order, "send "+holder); return nil },
|
||||||
|
}
|
||||||
|
startWithOwnGrants(t.Context(), c, func() { order = append(order, "writers") })
|
||||||
|
if strings.Join(order, ", ") != "send novox, writers" {
|
||||||
|
t.Fatalf("the start did %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1800 (3), (4): no send when the list is current or there is no bus with a list; one that cannot be read or sent is
|
||||||
|
// said once, and the controller goes on.
|
||||||
|
func TestAStartingControllerSendsNothingWhenTheListIsCurrentAndGoesOnWhenRefused(t *testing.T) {
|
||||||
|
sends := 0
|
||||||
|
send := func(context.Context, string) error { sends++; return nil }
|
||||||
|
for _, c := range []ownGrantsCarrier{
|
||||||
|
{behind: func(context.Context) (string, bool, error) { return "novox", false, nil }, send: send},
|
||||||
|
{behind: func(context.Context) (string, bool, error) { return "", true, nil }, send: send},
|
||||||
|
} {
|
||||||
|
if said := c.carry(t.Context()); said != "" || sends != 0 {
|
||||||
|
t.Fatalf("a current list sent %d and said %q", sends, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refused := ownGrantsCarrier{behind: func(context.Context) (string, bool, error) { return "novox", true, nil },
|
||||||
|
send: func(context.Context, string) error { return errNotGrantsOnly }}
|
||||||
|
went := false
|
||||||
|
startWithOwnGrants(t.Context(), refused, func() { went = true })
|
||||||
|
if !went {
|
||||||
|
t.Fatal("a refused send stopped the controller")
|
||||||
|
}
|
||||||
|
if said := refused.carry(t.Context()); !strings.Contains(said, "could not be sent to novox") ||
|
||||||
|
!strings.Contains(said, errNotGrantsOnly.Error()) {
|
||||||
|
t.Fatalf("a refused send is said as %q", said)
|
||||||
|
}
|
||||||
|
unread := ownGrantsCarrier{behind: func(context.Context) (string, bool, error) { return "", false, errors.New("no store") },
|
||||||
|
send: send}
|
||||||
|
if said := unread.carry(t.Context()); !strings.Contains(said, "cannot be read") || sends != 0 {
|
||||||
|
t.Fatalf("an unread list: sent %d, said %q", sends, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1800 (2): the start's send is the grants step's: composed with every build of the bus's machine kept at the one it
|
||||||
|
// runs, so the declaration differs from its last only by the user list (and is refused unsent otherwise,
|
||||||
|
// errNotGrantsOnly, release.go), whatever a running plan registered since.
|
||||||
|
func TestAStartingControllersSendKeepsEveryBuildOfTheBusMachine(t *testing.T) {
|
||||||
|
was := sendRollout
|
||||||
|
t.Cleanup(func() { sendRollout = was })
|
||||||
|
var kept bool
|
||||||
|
var named []string
|
||||||
|
sendRollout = func(ctx context.Context, _ *stores, names []string) ([]string, error) {
|
||||||
|
kept, named = everyBuildKept(ctx, "novox"), names
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
if err := ownGrantsOf(nil).send(t.Context(), "novox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !kept || len(named) != 1 || named[0] != "novox" {
|
||||||
|
t.Fatalf("the start's send keeps every build: %v, names %v", kept, named)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// B1 (ADR 0499 §1, §5; issue 1758): a module whose every artifact names the paths it is built from is read from those
|
||||||
|
// paths and its manifest alone. mesh-sdk's TypeScript package at its repository's root: a change to its go/ touches no
|
||||||
|
// module, one to its src/ or its package.json touches it. A module with an artifact naming no paths reads its directory.
|
||||||
|
func TestAModuleNamingItsPathsIsReadFromThemAlone(t *testing.T) {
|
||||||
|
m, err := catalogue.ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"lib",
|
||||||
|
"kind":"package","language":"typescript","paths":["src","package.json","package-lock.json","tsconfig.json"]}]},
|
||||||
|
"resources":[]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
own := pathsBuildSource(m, "")
|
||||||
|
read := inventory.WithBuildSources(nil, []inventory.BuildSource{{Paths: own}})
|
||||||
|
e := inventory.Entry{Manifest: m, Source: inventory.Source{Repository: "novox/mesh-sdk", Path: ""}}
|
||||||
|
for file, reads := range map[string]bool{
|
||||||
|
"go/delivery/builder.go": false, "go/go.mod": false, "README.md": false,
|
||||||
|
"src/index.ts": true, "src/deep/x.ts": true, "package.json": true, "module.json": true,
|
||||||
|
} {
|
||||||
|
if got := readsFile(e, read, file); got != reads {
|
||||||
|
t.Errorf("%s: read %v, want %v", file, got, reads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// In a module's directory, the paths are under it.
|
||||||
|
if got := pathsBuildSource(m, "modules/sdk"); got[0] != "modules/sdk/module.json" || got[1] != "modules/sdk/src/**" {
|
||||||
|
t.Errorf("paths under a directory: %v", got)
|
||||||
|
}
|
||||||
|
// An artifact naming no paths: the whole directory, as before.
|
||||||
|
m.Build.Artifacts = append(m.Build.Artifacts, catalogue.Artifact{Name: "files", Kind: "archive", From: "files"})
|
||||||
|
if got := pathsBuildSource(m, ""); got != nil {
|
||||||
|
t.Errorf("a module with an artifact naming no paths is narrowed to %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
-22
@@ -9,6 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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/conditions"
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
@@ -85,8 +88,30 @@ const (
|
|||||||
unknownModule = iota
|
unknownModule = iota
|
||||||
buildInFlight
|
buildInFlight
|
||||||
knownNotBuilt
|
knownNotBuilt
|
||||||
|
// keyComing is a module the indexer holds and the catalogue does not yet: registered from the builder's done key
|
||||||
|
// of it (ADR 0501 §6), which nobody asks.
|
||||||
|
keyComing
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// keyPendingPrefix opens the build a pending assignment waits for when it waits for a module's key: no build is asked
|
||||||
|
// (ADR 0501 §5, §6; issue 1851, A10), so it names the module's key, not a build id.
|
||||||
|
const keyPendingPrefix = "key:"
|
||||||
|
|
||||||
|
// indexedModule is the indexer's trunk value of a module the catalogue does not hold, found when it holds one. A
|
||||||
|
// variable so a test needs no bus.
|
||||||
|
var indexedModule = func(ctx context.Context, module string) (sdkdelivery.Module, bool, error) {
|
||||||
|
keys, closeKeys, err := openBuilderKeys()
|
||||||
|
if err != nil {
|
||||||
|
return sdkdelivery.Module{}, false, err
|
||||||
|
}
|
||||||
|
defer closeKeys()
|
||||||
|
v, err := keys.ModuleAt(ctx, module, "")
|
||||||
|
if errors.Is(err, builderkeys.ErrNoValue) {
|
||||||
|
return sdkdelivery.Module{}, false, nil
|
||||||
|
}
|
||||||
|
return v, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// notHeld is where a module the catalogue does not hold stands: its case, the build request it was read
|
// notHeld is where a module the catalogue does not hold stands: its case, the build request it was read
|
||||||
// from, and the sentence that says it.
|
// from, and the sentence that says it.
|
||||||
type notHeld struct {
|
type notHeld struct {
|
||||||
@@ -96,7 +121,24 @@ type notHeld struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// whereIs looks for a module the catalogue does not hold among the build requests the controller keeps.
|
// whereIs looks for a module the catalogue does not hold among the build requests the controller keeps.
|
||||||
func whereIs(ctx context.Context, inv *inventory.Inventory, module string, now time.Time) (notHeld, error) {
|
// notHeldReads is what whereIs reads of the records (*inventory.Inventory, or a test's).
|
||||||
|
type notHeldReads interface {
|
||||||
|
RequestsNamed(ctx context.Context, name string) ([]inventory.RequestOutcome, error)
|
||||||
|
Catalogue(ctx context.Context) (map[string]catalogue.Manifest, error)
|
||||||
|
RequestedNames(ctx context.Context) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func whereIs(ctx context.Context, inv notHeldReads, module string, now time.Time) (notHeld, error) {
|
||||||
|
// **Its key is coming** (ADR 0501 §6): the indexer holds it, and the controller registers it from the builder's done
|
||||||
|
// key of it. Read before the build requests of before R4e, which nothing writes any more.
|
||||||
|
if v, held, err := indexedModule(ctx, module); err != nil {
|
||||||
|
return notHeld{}, fmt.Errorf("whether the indexer holds %s cannot be read: %w", module, err)
|
||||||
|
} else if held {
|
||||||
|
return notHeld{kind: keyComing, request: inventory.RequestOutcome{BuildRequest: inventory.BuildRequest{
|
||||||
|
ID: keyPendingPrefix + module, Repository: v.Repository, Seat: gitSeat, Path: keyPath(v)}},
|
||||||
|
said: fmt.Sprintf("%s is not registered yet: the indexer holds it in %s at %.8s, and it is registered from "+
|
||||||
|
"the builder's done key of it, which no one asks", module, v.Repository, v.Commit)}, nil
|
||||||
|
}
|
||||||
requests, err := inv.RequestsNamed(ctx, module)
|
requests, err := inv.RequestsNamed(ctx, module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return notHeld{}, err
|
return notHeld{}, err
|
||||||
@@ -169,6 +211,12 @@ func notRegistered(ctx context.Context, open *stores, node, module string, opts
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
switch where.kind {
|
switch where.kind {
|
||||||
|
case keyComing:
|
||||||
|
lead := where.said
|
||||||
|
if opts.Build {
|
||||||
|
lead += "\n build \"true\" asks nothing: the builder builds every fingerprint the indexer writes (ADR 0501 §6)"
|
||||||
|
}
|
||||||
|
return keepPending(ctx, open, node, module, where.request, waiting, lead)
|
||||||
case buildInFlight:
|
case buildInFlight:
|
||||||
lead := where.said
|
lead := where.said
|
||||||
if opts.Build {
|
if opts.Build {
|
||||||
@@ -183,30 +231,20 @@ func notRegistered(ctx context.Context, open *stores, node, module string, opts
|
|||||||
also = fmt.Sprintf("\n %s already has a pending assignment of %s, waiting for build %s; build "+
|
also = fmt.Sprintf("\n %s already has a pending assignment of %s, waiting for build %s; build "+
|
||||||
"\"true\" asks for a new build and makes it wait for that one", node, module, waiting.Build)
|
"\"true\" asks for a new build and makes it wait for that one", node, module, waiting.Build)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("%w: %s%s\n assign it again with build \"true\" to ask for its build and keep this "+
|
return "", fmt.Errorf("%w: %s%s\n it is registered from the builder's done key once a merge adds it to %s "+
|
||||||
"assignment until the build registers it; or `build` it (repository %s, path %s) and assign it "+
|
"(path %s) and the indexer holds it, or at the controller's terminal; assign it then",
|
||||||
"once it is registered", inventory.ErrNoSuchModule, where.said, also, last.Repository, orRoot(last.Path))
|
inventory.ErrNoSuchModule, where.said, also, last.Repository, orRoot(last.Path))
|
||||||
}
|
}
|
||||||
if waiting != nil && waiting.State == inventory.PendingApplying {
|
// **No build is asked** (ADR 0501 §5, §6): a module the indexer does not hold has no key coming.
|
||||||
return "", fmt.Errorf("%s is being assigned %s now; nothing was asked", node, module)
|
return "", fmt.Errorf("%w: %s\n build \"true\" asks no build: the controller asks none (ADR 0501 §5); a module "+
|
||||||
}
|
"is registered from the builder's done key once a merge adds it to a repository the catalogue builds from, or "+
|
||||||
source := buildSource{Repository: last.Repository, Seat: last.Seat}
|
"at the controller's terminal, and the indexer holds no %s", inventory.ErrNoSuchModule, where.said, module)
|
||||||
id, err := askABuild(ctx, source, last.Path, last.Ref)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%s\n its build could not be asked for: %w; nothing was assigned", where.said, err)
|
|
||||||
}
|
|
||||||
asked := inventory.RequestOutcome{BuildRequest: inventory.BuildRequest{ID: id, Repository: last.Repository,
|
|
||||||
Seat: last.Seat, Path: last.Path, Ref: last.Ref, For: "assign", At: now.UTC()}}
|
|
||||||
recordBuildRequest(ctx, inv, asked.BuildRequest)
|
|
||||||
lead := fmt.Sprintf("%s\n build %s of %s is asked for now; %s can be assigned once it registers it",
|
|
||||||
where.said, id, requestSource(asked), module)
|
|
||||||
return keepPending(ctx, open, node, module, asked, waiting, lead)
|
|
||||||
}
|
}
|
||||||
answer := strings.TrimPrefix(where.said, inventory.ErrNoSuchModule.Error()) +
|
answer := strings.TrimPrefix(where.said, inventory.ErrNoSuchModule.Error()) +
|
||||||
"\n a module in a repository is built with `build` (its repository and path), then assigned"
|
"\n a module in a repository is registered from the builder's done key of it, then assigned"
|
||||||
if opts.Build {
|
if opts.Build {
|
||||||
answer += "\n build \"true\" asks for nothing here: the controller has no build request saying where a " +
|
answer += "\n build \"true\" asks for nothing: the controller asks no build (ADR 0501 §5), and the indexer " +
|
||||||
"module of that name is"
|
"holds no module of that name"
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("%w%s", inventory.ErrNoSuchModule, answer)
|
return "", fmt.Errorf("%w%s", inventory.ErrNoSuchModule, answer)
|
||||||
}
|
}
|
||||||
@@ -316,7 +354,7 @@ func clock(t time.Time) string { return t.Local().Format("2006-01-02 15:04:05 MS
|
|||||||
|
|
||||||
// closestNames is up to three names near the one asked: registered modules and directories the controller
|
// closestNames is up to three names near the one asked: registered modules and directories the controller
|
||||||
// asked to build.
|
// asked to build.
|
||||||
func closestNames(ctx context.Context, inv *inventory.Inventory, name string) []string {
|
func closestNames(ctx context.Context, inv notHeldReads, name string) []string {
|
||||||
var candidates []string
|
var candidates []string
|
||||||
if shelf, err := inv.Catalogue(ctx); err == nil {
|
if shelf, err := inv.Catalogue(ctx); err == nil {
|
||||||
for m := range shelf {
|
for m := range shelf {
|
||||||
@@ -535,6 +573,19 @@ func settlePending(ctx context.Context, open *stores, now time.Time) []string {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(p.Build, keyPendingPrefix) {
|
||||||
|
why, err := keyPendingEnded(ctx, p.Module)
|
||||||
|
if err != nil {
|
||||||
|
say("the key %s waits for cannot be read: %v", p.Module, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if why != "" {
|
||||||
|
if line := expirePending(ctx, inv, p, why); line != "" {
|
||||||
|
said = append(said, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
requests, err := inv.RequestsNamed(ctx, p.Module)
|
requests, err := inv.RequestsNamed(ctx, p.Module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("the build requests for %s could not be read: %v", p.Module, err)
|
say("the build requests for %s could not be read: %v", p.Module, err)
|
||||||
@@ -744,3 +795,46 @@ func withdrawPending(ctx context.Context, inv *inventory.Inventory, node, module
|
|||||||
}
|
}
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keyPendingEnded is why a pending assignment waiting for a module's key ends: the indexer no longer holds the module,
|
||||||
|
// or the builder's key of its fingerprint failed; empty while it is coming. A variable so a test needs no bus.
|
||||||
|
var keyPendingEnded = func(ctx context.Context, module string) (string, error) {
|
||||||
|
keys, closeKeys, err := openBuilderKeys()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer closeKeys()
|
||||||
|
v, err := keys.ModuleAt(ctx, module, "")
|
||||||
|
if errors.Is(err, builderkeys.ErrNoValue) {
|
||||||
|
return fmt.Sprintf("the indexer no longer holds %s", module), nil
|
||||||
|
}
|
||||||
|
if err != nil || v.FP2 == "" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
k, err := keys.Build(ctx, module, v.FP2)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if k.Failed() {
|
||||||
|
return k.Says(), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// settlePendingOnKey assigns, now, every pending assignment of a module its done key just registered (ADR 0501 §6):
|
||||||
|
// what an outcome of a build did for one waiting on that build.
|
||||||
|
func settlePendingOnKey(ctx context.Context, open *stores, module string) []string {
|
||||||
|
waiting, err := open.inventory.Pending(ctx, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
return []string{fmt.Sprintf("the pending assignments could not be read after %s was registered: %v", module, err)}
|
||||||
|
}
|
||||||
|
var said []string
|
||||||
|
for _, p := range waiting {
|
||||||
|
if p.Module == module {
|
||||||
|
if line := applyPending(ctx, open, p, keyPendingPrefix+module); line != "" {
|
||||||
|
said = append(said, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return said
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -13,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
)
|
)
|
||||||
@@ -36,11 +36,13 @@ func aCatalogueMesh(t *testing.T) *stores {
|
|||||||
return open
|
return open
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeAdding keeps the build request a merge adding a module asked before R4e (issue 300): nothing asks one since (ADR
|
||||||
|
// 0501 §6), and the pending assignments still read the ones kept. These tests are of what an assignment does with one.
|
||||||
func mergeAdding(t *testing.T, open *stores, dir, commit string) {
|
func mergeAdding(t *testing.T, open *stores, dir, commit string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit,
|
if err := open.inventory.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: "b-" + dir,
|
||||||
Paths: []string{dir + "/module.json"}, ModuleDirs: []string{dir}, ModuleDirsSaid: true}
|
Repository: "novox/mesh-catalog", Seat: "git", Path: dir, Ref: "main", Commit: commit, For: "merge",
|
||||||
if err := (following{open: open}).SourceMoved(t.Context(), m); err != nil {
|
At: time.Now().UTC()}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +177,7 @@ func TestAPendingAssignmentExpiresWhenItsBuildFails(t *testing.T) {
|
|||||||
t.Fatalf("a module whose build failed: %v", err)
|
t.Fatalf("a module whose build failed: %v", err)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"sensors is known and not registered", "its last build, b-modules/sensors",
|
for _, want := range []string{"sensors is known and not registered", "its last build, b-modules/sensors",
|
||||||
"failed: go build: undefined: sensorsRead", "build \"true\"", "repository novox/mesh-catalog, path modules/sensors"} {
|
"failed: go build: undefined: sensorsRead", "registered from the builder's done key", "novox/mesh-catalog (path modules/sensors)"} {
|
||||||
if !strings.Contains(err.Error(), want) {
|
if !strings.Contains(err.Error(), want) {
|
||||||
t.Fatalf("the refusal does not say %q:\n%v", want, err)
|
t.Fatalf("the refusal does not say %q:\n%v", want, err)
|
||||||
}
|
}
|
||||||
@@ -225,100 +227,10 @@ func TestTheConditionClearsWhenTheModuleIsAssignedLater(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAssignWithBuildAsksForAKnownModule(t *testing.T) {
|
|
||||||
open := aCatalogueMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
inv := open.inventory
|
|
||||||
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-old", Repository: "novox/mesh-catalog",
|
|
||||||
Seat: "git", Path: "modules/sensors", Ref: "main", For: "build", At: time.Now().Add(-time.Hour)}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-old", Repository: "novox/mesh-catalog", Ref: "main",
|
|
||||||
Path: "modules/sensors", On: "anchor", Failed: "no space left on device"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
asked := asksWithPaths(t)
|
|
||||||
said, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(*asked) != 1 || (*asked)[0] != [3]string{"novox/mesh-catalog", "modules/sensors", "main"} {
|
|
||||||
t.Fatalf("asked %v", *asked)
|
|
||||||
}
|
|
||||||
for _, want := range []string{"known and not registered", "no space left on device",
|
|
||||||
"build b-modules/sensors of novox/mesh-catalog@main (modules/sensors) is asked for now", "kept as pending"} {
|
|
||||||
if !strings.Contains(said, want) {
|
|
||||||
t.Fatalf("the answer does not say %q:\n%s", want, said)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pending := pendingOf(t, open)
|
|
||||||
if len(pending) != 1 || pending[0].Build != "b-modules/sensors" {
|
|
||||||
t.Fatalf("pending: %+v", pending)
|
|
||||||
}
|
|
||||||
requests, _ := inv.RequestsNamed(ctx, "sensors")
|
|
||||||
if len(requests) != 2 || requests[0].ID != "b-modules/sensors" || requests[0].For != "assign" {
|
|
||||||
t.Fatalf("the request is not kept as assign's: %+v", requests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssignWithBuildRepointsTheWaitingAssignment(t *testing.T) {
|
|
||||||
open := aCatalogueMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
inv := open.inventory
|
|
||||||
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-old", Repository: "novox/mesh-catalog",
|
|
||||||
Seat: "git", Path: "modules/sensors", Ref: "main", For: "merge", At: time.Now().Add(-time.Hour)}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-old", Repository: "novox/mesh-catalog", Ref: "main",
|
|
||||||
Path: "modules/sensors", On: "anchor", Failed: "boom"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Waiting on the failed build, its expiry not yet settled.
|
|
||||||
if _, err := inv.RecordPending(ctx, inventory.PendingAssignment{Node: "laptop", Module: "sensors",
|
|
||||||
Build: "build-old", Repository: "novox/mesh-catalog", Path: "modules/sensors"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err := assign(ctx, open, "laptop", "sensors")
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "already has a pending assignment of sensors, waiting for build build-old") {
|
|
||||||
t.Fatalf("known and not built with a pending assignment waiting: %v", err)
|
|
||||||
}
|
|
||||||
asked := asksWithPaths(t)
|
|
||||||
said, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
|
|
||||||
if err != nil || !strings.Contains(said, "which waited for build build-old, now waits for build b-modules/sensors") {
|
|
||||||
t.Fatalf("assign with build: %q %v", said, err)
|
|
||||||
}
|
|
||||||
if len(*asked) != 1 {
|
|
||||||
t.Fatalf("asked %v", *asked)
|
|
||||||
}
|
|
||||||
pending := pendingOf(t, open)
|
|
||||||
if len(pending) != 1 || pending[0].Build != "b-modules/sensors" || pending[0].State != inventory.PendingWaiting {
|
|
||||||
t.Fatalf("pending: %+v", pending)
|
|
||||||
}
|
|
||||||
// The new build in flight now: build "true" again asks nothing.
|
|
||||||
if said, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors"); err != nil ||
|
|
||||||
!strings.Contains(said, "no second build was asked") || len(*asked) != 1 {
|
|
||||||
t.Fatalf("a second build true while the build runs: %q %v, asked %v", said, err, *asked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAFailedAskIsNotABuildInFlight(t *testing.T) {
|
func TestAFailedAskIsNotABuildInFlight(t *testing.T) {
|
||||||
open := aCatalogueMesh(t)
|
open := aCatalogueMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
was := askABuild
|
|
||||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
|
||||||
return "", errors.New("cannot submit a build: nats: timeout")
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { askABuild = was })
|
|
||||||
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
|
||||||
_, err := assign(ctx, open, "laptop", "sensors")
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "the merge that added it (3da80a4b) could not ask for its build: cannot submit") {
|
|
||||||
t.Fatalf("a merge that could not ask: %v", err)
|
|
||||||
}
|
|
||||||
if len(pendingOf(t, open)) != 0 {
|
|
||||||
t.Fatal("a pending assignment waits on a build never asked")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-waited", Repository: "novox/mesh-catalog",
|
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-waited", Repository: "novox/mesh-catalog",
|
||||||
Seat: "git", Path: "modules/gauges", Ref: "main", For: "build"}); err != nil {
|
Seat: "git", Path: "modules/gauges", Ref: "main", For: "build"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -326,7 +238,7 @@ func TestAFailedAskIsNotABuildInFlight(t *testing.T) {
|
|||||||
if err := inv.MarkNotAsked(ctx, "build-waited", "cannot submit a build: no responders"); err != nil {
|
if err := inv.MarkNotAsked(ctx, "build-waited", "cannot submit a build: no responders"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err = assign(ctx, open, "laptop", "gauges")
|
_, err := assign(ctx, open, "laptop", "gauges")
|
||||||
if err == nil || !strings.Contains(err.Error(), "build build-waited, asked by build, was not handed over") {
|
if err == nil || !strings.Contains(err.Error(), "build build-waited, asked by build, was not handed over") {
|
||||||
t.Fatalf("a build not handed over: %v", err)
|
t.Fatalf("a build not handed over: %v", err)
|
||||||
}
|
}
|
||||||
@@ -395,7 +307,7 @@ func TestAnUnknownModuleIsRefusedWithTheClosestNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, want := range []string{"no module of that name: sensors", "the catalogue holds no module of that name",
|
for _, want := range []string{"no module of that name: sensors", "the catalogue holds no module of that name",
|
||||||
"no build request the controller kept (the last 30 days)", "the closest names it holds: sensord",
|
"no build request the controller kept (the last 30 days)", "the closest names it holds: sensord",
|
||||||
"asks for nothing here"} {
|
"asks for nothing: the controller asks no build"} {
|
||||||
if !strings.Contains(err.Error(), want) {
|
if !strings.Contains(err.Error(), want) {
|
||||||
t.Fatalf("the refusal does not say %q:\n%v", want, err)
|
t.Fatalf("the refusal does not say %q:\n%v", want, err)
|
||||||
}
|
}
|
||||||
@@ -565,59 +477,6 @@ func TestAPendingAssignmentNeverWaitsSilently(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestASecondAssignmentNotMadeKeepsItsCondition(t *testing.T) {
|
|
||||||
open := aCatalogueMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
asked := 0
|
|
||||||
was := askABuild
|
|
||||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
|
||||||
asked++
|
|
||||||
return fmt.Sprintf("b-%d", asked), nil
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { askABuild = was })
|
|
||||||
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
|
||||||
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
failedBuild(t, open, "b-1")
|
|
||||||
settlePending(ctx, open, time.Now())
|
|
||||||
first := pendingOf(t, open)[0]
|
|
||||||
if !conditionOpen(t, first) {
|
|
||||||
t.Fatal("the first assignment not made raised nothing")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
failedBuild(t, open, "b-2")
|
|
||||||
settlePending(ctx, open, time.Now())
|
|
||||||
|
|
||||||
var second inventory.PendingAssignment
|
|
||||||
for _, p := range pendingOf(t, open) {
|
|
||||||
if p.Build == "b-2" {
|
|
||||||
second = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if second.State != inventory.PendingExpired {
|
|
||||||
t.Fatalf("the second: %+v", second)
|
|
||||||
}
|
|
||||||
if !conditionOpen(t, second) {
|
|
||||||
t.Fatal("the second assignment's condition was cleared in the tick that raised it")
|
|
||||||
}
|
|
||||||
if !conditionOpen(t, rowOf(t, open, first.ID)) {
|
|
||||||
t.Fatal("the first was judged answered by a second that itself was not made")
|
|
||||||
}
|
|
||||||
// unassign takes both back, and the next tick clears both.
|
|
||||||
if said, err := unassign(ctx, open, "laptop", "sensors"); err != nil || !strings.Contains(said, "b-1") ||
|
|
||||||
!strings.Contains(said, "b-2") {
|
|
||||||
t.Fatalf("unassign: %q %v", said, err)
|
|
||||||
}
|
|
||||||
settlePending(ctx, open, time.Now())
|
|
||||||
if conditionOpen(t, first) || conditionOpen(t, second) {
|
|
||||||
t.Fatal("a condition stayed open after both were taken back")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestARaisedRowIsKeptUntilItsConditionClears(t *testing.T) {
|
func TestARaisedRowIsKeptUntilItsConditionClears(t *testing.T) {
|
||||||
open := aCatalogueMesh(t)
|
open := aCatalogueMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -735,3 +594,31 @@ func TestABuildNoLongerWaitedForIsStillInFlight(t *testing.T) {
|
|||||||
t.Fatalf("past the bound: %+v", rows)
|
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) {
|
||||||
|
open := aCatalogueMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
noBuildAsked(t)
|
||||||
|
b := aBuilderOnTheBus(t)
|
||||||
|
fp := fpOf('a')
|
||||||
|
manifest := b.indexed(t, "sensors", "1", "novox/mesh-catalog", "modules/sensors", "3da80a4b00aa0000", fp)
|
||||||
|
b.built(t, "sensors", "1", fp, manifest, sdkdelivery.BuildBuilding)
|
||||||
|
said, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
|
||||||
|
if err != nil || !strings.Contains(said, "registered from the builder's done key") || !strings.Contains(said, "kept as pending") {
|
||||||
|
t.Fatalf("A10: the assignment of a module whose key is coming: %q %v", said, err)
|
||||||
|
}
|
||||||
|
if p := pendingOf(t, open); len(p) != 1 || p[0].Build != keyPendingPrefix+"sensors" {
|
||||||
|
t.Fatalf("A10: pending %+v", p)
|
||||||
|
}
|
||||||
|
settlePending(ctx, open, time.Now().Add(time.Hour))
|
||||||
|
if p := pendingOf(t, open); p[0].State != inventory.PendingWaiting {
|
||||||
|
t.Fatalf("A10: a pending assignment waiting for a building key expired: %+v", p)
|
||||||
|
}
|
||||||
|
b.built(t, "sensors", "1", fp, manifest, sdkdelivery.BuildDone)
|
||||||
|
takeNewModules(ctx, open, "sensors")
|
||||||
|
if !slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
||||||
|
t.Fatalf("A10: the key registered sensors and the pending assignment was not made: %+v", pendingOf(t, open))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"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/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A10 (ADR 0501 §6; issue 1851): a module the catalogue does not hold and the indexer does is one whose key is coming:
|
||||||
|
// an assignment of it waits for that key, named by the module, with no build asked and no build request read.
|
||||||
|
func TestAModuleTheIndexerHoldsIsWaitedForByItsKey(t *testing.T) {
|
||||||
|
was := indexedModule
|
||||||
|
indexedModule = func(_ context.Context, module string) (sdkdelivery.Module, bool, error) {
|
||||||
|
return sdkdelivery.Module{Repository: "novox/mesh-catalog", Commit: "3da80a4b00aa", Path: "modules/" + module}, true, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { indexedModule = was })
|
||||||
|
where, err := whereIs(t.Context(), noRequests{}, "sensors", time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if where.kind != keyComing || where.request.ID != keyPendingPrefix+"sensors" || where.request.Path != "modules/sensors" ||
|
||||||
|
!strings.Contains(where.said, "registered from the builder's done key") {
|
||||||
|
t.Fatalf("a module whose key is coming is read as %+v", where)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noRequests is records with no build request and no module: what a mesh after R4e reads of a new module.
|
||||||
|
type noRequests struct{}
|
||||||
|
|
||||||
|
func (noRequests) RequestsNamed(context.Context, string) ([]inventory.RequestOutcome, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (noRequests) Catalogue(context.Context) (map[string]catalogue.Manifest, error) { return nil, nil }
|
||||||
|
func (noRequests) RequestedNames(context.Context) ([]string, error) { return nil, nil }
|
||||||
@@ -457,6 +457,18 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
|||||||
Explanation: "A delivery did not finish. Nothing is tried again until you say so.",
|
Explanation: "A delivery did not finish. Nothing is tried again until you say so.",
|
||||||
Resolved: "Resolved: the delivery is no longer failed"}
|
Resolved: "Resolved: the delivery is no longer failed"}
|
||||||
}),
|
}),
|
||||||
|
kindMovesHeld: worded(func(o conditions.Observation) words {
|
||||||
|
return words{Headline: "An update waits for you",
|
||||||
|
Explanation: "A new version of a module would restart a container or touch its data, so it waits for you.",
|
||||||
|
Resolved: "Resolved: the update is no longer waiting"}
|
||||||
|
}),
|
||||||
|
kindImageMissing: worded(func(o conditions.Observation) words {
|
||||||
|
return words{Headline: "A module is not updated",
|
||||||
|
Explanation: "The new version of a module names a container image that the mesh's store does not hold, so it " +
|
||||||
|
"was not sent. What runs is untouched.",
|
||||||
|
Needs: "build the module again; the details name the image.",
|
||||||
|
Resolved: "Resolved: the store holds the image again"}
|
||||||
|
}),
|
||||||
"ask-lost": worded(func(o conditions.Observation) words {
|
"ask-lost": worded(func(o conditions.Observation) words {
|
||||||
return words{Headline: "A build has no result",
|
return words{Headline: "A build has no result",
|
||||||
Explanation: "A build was asked for and never answered. It is asked again with the next delivery.",
|
Explanation: "A build was asked for and never answered. It is asked again with the next delivery.",
|
||||||
@@ -954,6 +966,9 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio
|
|||||||
if l.State == "unanswered" {
|
if l.State == "unanswered" {
|
||||||
return unansweredWords(l, o)
|
return unansweredWords(l, o)
|
||||||
}
|
}
|
||||||
|
if l.Needs == needsNewHead {
|
||||||
|
return newHeadWords(l, name)
|
||||||
|
}
|
||||||
held := l.State
|
held := l.State
|
||||||
if held == "" {
|
if held == "" {
|
||||||
held = "held"
|
held = "held"
|
||||||
@@ -987,6 +1002,29 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio
|
|||||||
fmt.Sprintf("Delivery of %s is no longer %s", name, held), needs, actions
|
fmt.Sprintf("Delivery of %s is no longer %s", name, held), needs, actions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newHeadWords are the words of a delivery whose cure is its author's new head (novox/hq ADR 0505 §5): it names the
|
||||||
|
// author and says a new head is needed; it offers no Stop and never calls it the operator's, because the operator
|
||||||
|
// hears of it only once the author's own watch has failed, after two hours with no new head.
|
||||||
|
func newHeadWords(l stalledLine, name string) (headline, explanation, resolved, needs string, actions []conditions.Action) {
|
||||||
|
pull := "A pull request of " + name
|
||||||
|
if l.Number > 0 {
|
||||||
|
pull = fmt.Sprintf("Pull request %d of %s", l.Number, name)
|
||||||
|
}
|
||||||
|
author := l.Author
|
||||||
|
if _, plain := conditions.Plain(author); !plain {
|
||||||
|
author = "its author" // a branch named in code (a dotted name, a number) is not said
|
||||||
|
}
|
||||||
|
long := "for a long time"
|
||||||
|
if d, err := time.ParseDuration(l.For); err == nil {
|
||||||
|
long = "for " + humanDuration(d)
|
||||||
|
}
|
||||||
|
return pull + " needs a new head from its author",
|
||||||
|
fmt.Sprintf("%s cannot merge until %s pushes a new head: its check failed after its Approve, and no new head "+
|
||||||
|
"came %s.", pull, author, long),
|
||||||
|
pull + " has a new head, or is closed",
|
||||||
|
"ask " + author + " for a new head.", nil
|
||||||
|
}
|
||||||
|
|
||||||
// stoppedNeeds is what ends a delivery that is already stopped, from the stalled line's healer words ("none: a
|
// stoppedNeeds is what ends a delivery that is already stopped, from the stalled line's healer words ("none: a
|
||||||
// merge that delivers its modules ends it — the operator's"): the part that says what ends it.
|
// merge that delivers its modules ends it — the operator's"): the part that says what ends it.
|
||||||
func stoppedNeeds(l stalledLine) string {
|
func stoppedNeeds(l stalledLine) string {
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ func TestUpdatesHeldNameWhatWaitsAndWhereItIsReleased(t *testing.T) {
|
|||||||
plainExample(t, got[0], "Updates of openrazer and sensors wait for your release",
|
plainExample(t, got[0], "Updates of openrazer and sensors wait for your release",
|
||||||
"Needs you: release them from the mesh MCP server; this notification cannot do it. Updates of openrazer and "+
|
"Needs you: release them from the mesh MCP server; this notification cannot do it. Updates of openrazer and "+
|
||||||
"sensors on g14 and shanks wait for a person to release them, because the last walk failed. "+
|
"sensors on g14 and shanks wait for a person to release them, because the last walk failed. "+
|
||||||
"They are not delivered until then, and stay held if you leave them.")
|
"They are not delivered until then, and stay held if you leave them.", "Release")
|
||||||
}
|
}
|
||||||
|
|
||||||
// **What held them is said in the glossary's words** (2026-10-08 review): the backlog is held after any
|
// **What held them is said in the glossary's words** (2026-10-08 review): the backlog is held after any
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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.
|
||||||
|
var buildAgentsOwn = map[string]bool{sdkdelivery.BuilderModule: true, builder.AgentModule: true, "mesh-tools": 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return r, closeKeys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeFromKey marks a module of a walk's tier as taken from the builder's key: asked of nobody (ADR 0501 §6).
|
||||||
|
func takeFromKey(p *inventory.Plan, state *inventory.PlanModule, e inventory.Entry, now time.Time) {
|
||||||
|
state.BusWaitFrom = nil
|
||||||
|
state.State = "asked"
|
||||||
|
state.AskedAt = &now
|
||||||
|
state.Key = true
|
||||||
|
state.Build, state.Fingerprint = "", ""
|
||||||
|
state.KeyAt = p.CommitOn(e.Source.Repository, followedBranch(e.Source.Ref))
|
||||||
|
state.Why = "waits for the builder's key"
|
||||||
|
state.Commit, state.BuiltAt, state.Made = "", nil, ""
|
||||||
|
fmt.Printf(" tier %d: %s is taken from the builder's key, asking no build\n", p.Tier, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyTakes are the take-ins of keys a look started and has not seen end, by key build id, and what each said when it
|
||||||
|
// waits: kept in this process, so a look starts each once and a settle says why it waits.
|
||||||
|
var keyTakes = struct {
|
||||||
|
sync.Mutex
|
||||||
|
running map[string]bool
|
||||||
|
waits map[string]string
|
||||||
|
// taken are the keys whose take-in ended registered, or found the module already at a later build (superseded).
|
||||||
|
taken map[string]bool
|
||||||
|
}{running: map[string]bool{}, waits: map[string]string{}, taken: map[string]bool{}}
|
||||||
|
|
||||||
|
// settleFromKeys settles every module of the tier taken from a key: built from its key's record, failed with the key's
|
||||||
|
// words or the take-in's refusal, or still waiting, saying for what. It says whether it changed the plan. It reads only
|
||||||
|
// 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) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
RefusedOf(ctx context.Context, ids []string) (map[string]string, error)
|
||||||
|
SourceOf(ctx context.Context, module string) (inventory.Source, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleKeys is settleFromKeys on the records handed in, starting a take-in with take.
|
||||||
|
func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inventory.Plan, tier []string,
|
||||||
|
take func(module string, trunk sdkdelivery.Module, id string)) (bool, error) {
|
||||||
|
changed := false
|
||||||
|
set := func(s *inventory.PlanModule, why string) {
|
||||||
|
if s.Why != why {
|
||||||
|
s.Why = why
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range tier {
|
||||||
|
s := p.Modules[m]
|
||||||
|
if s == nil || !s.Key || s.State != "asked" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trunk, err := keys.ModuleAt(ctx, m, s.KeyAt)
|
||||||
|
if errors.Is(err, builderkeys.ErrNoValue) {
|
||||||
|
set(s, fmt.Sprintf("waits for the indexer's value of %s at %s", m, orNow(s.KeyAt)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
if trunk.FP2 == "" {
|
||||||
|
why := trunk.None2
|
||||||
|
if why == "" {
|
||||||
|
why = "the indexer wrote no fingerprint of the new form"
|
||||||
|
}
|
||||||
|
set(s, fmt.Sprintf("waits: %s at %s has no fingerprint to take a build by: %s", m, orNow(s.KeyAt), why))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Fingerprint != trunk.FP2 {
|
||||||
|
s.Fingerprint, changed = trunk.FP2, true
|
||||||
|
}
|
||||||
|
k, err := keys.Build(ctx, m, trunk.FP2)
|
||||||
|
if err != nil {
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
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.
|
||||||
|
s.State, s.Why = "failed", k.Build.Error
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = fmt.Sprintf("%s failed to build in tier %d: %s", m, p.Tier, firstLine(k.Build.Error))
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
case !k.Done() || len(k.Unreferenced()) > 0:
|
||||||
|
set(s, "waits: "+k.Says())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := keyBuildID(k)
|
||||||
|
refused, err := inv.RefusedOf(ctx, []string{id})
|
||||||
|
if err != nil {
|
||||||
|
return changed, fmt.Errorf("the refused registrations cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
for kept, why := range refusalsKeptFor([]string{id}) {
|
||||||
|
refused[kept] = why
|
||||||
|
}
|
||||||
|
if why, ok := refused[id]; ok {
|
||||||
|
s.State, s.Why, s.Build = "failed", why, id
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = fmt.Sprintf("%s failed to build in tier %d: %s", m, p.Tier, firstLine(why))
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, found, err := inv.BuildByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
if found && b.Worked() {
|
||||||
|
keyTakes.Lock()
|
||||||
|
taken := keyTakes.taken[id]
|
||||||
|
keyTakes.Unlock()
|
||||||
|
registered := taken
|
||||||
|
if !registered {
|
||||||
|
if registered, err = registeredAt(ctx, inv, m, trunk.Commit); err != nil {
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if registered {
|
||||||
|
at := b.At
|
||||||
|
s.State, s.BuiltAt, s.Commit, s.Build = "built", &at, trunk.Commit, id
|
||||||
|
s.Made = fmt.Sprintf("taken from the builder's key %s (revision %d), no build asked", short(trunk.FP2), k.Revision)
|
||||||
|
s.Why = ""
|
||||||
|
fmt.Printf("%s: %s taken from the builder's key %s\n", p.ID, m, short(trunk.FP2))
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyTakes.Lock()
|
||||||
|
running, waits := keyTakes.running[id], keyTakes.waits[id]
|
||||||
|
if !running {
|
||||||
|
keyTakes.running[id] = true
|
||||||
|
}
|
||||||
|
keyTakes.Unlock()
|
||||||
|
if waits != "" {
|
||||||
|
set(s, firstLine(waits))
|
||||||
|
} else {
|
||||||
|
set(s, "taking in the builder's done key "+short(trunk.FP2))
|
||||||
|
}
|
||||||
|
if !running {
|
||||||
|
take(m, trunk, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// registeredAt says the catalogue holds the module registered at the commit the walk takes it at: its key's take-in
|
||||||
|
// registered it there, whether with the key's artifacts or, unchanged, with the build it stands on (no move). A key
|
||||||
|
// recorded at an earlier commit (the same fingerprint at an older merge) is taken in again, to register it here.
|
||||||
|
func registeredAt(ctx context.Context, inv keyRecords, module, commit string) (bool, error) {
|
||||||
|
src, err := inv.SourceOf(ctx, module)
|
||||||
|
if errors.Is(err, inventory.ErrNoSuchModule) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return sameCommitID(src.BuiltFrom, commit), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCommitID(a, b string) bool {
|
||||||
|
if a == "" || b == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
n := min(len(a), len(b))
|
||||||
|
return n >= 7 && a[:n] == b[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func orNow(commit string) string {
|
||||||
|
if commit == "" {
|
||||||
|
return "its value now"
|
||||||
|
}
|
||||||
|
return short(commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeKeyOffTheHold takes a walk's module in from its done key, off the plans' hold: a registration may sweep the
|
||||||
|
// artifact store, and the plans are held by one look at a time. Its end asks the look, which settles the module from the
|
||||||
|
// record; a refusal is kept by the key's build id, so the look fails the walk with it; a wait is said by the look.
|
||||||
|
func takeKeyOffTheHold(ctx context.Context, open *stores, module string, trunk sdkdelivery.Module, id string) {
|
||||||
|
defer func() {
|
||||||
|
keyTakes.Lock()
|
||||||
|
delete(keyTakes.running, id)
|
||||||
|
keyTakes.Unlock()
|
||||||
|
askToLook()
|
||||||
|
}()
|
||||||
|
keys, closeKeys, err := openBuilderKeys()
|
||||||
|
if err != nil {
|
||||||
|
keepKeyWait(id, "waits: the builder's keys cannot be read: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer closeKeys()
|
||||||
|
_, _, err = takeInKey(ctx, open.inventory, keys, keyTake{Module: module, Trunk: trunk,
|
||||||
|
Ask: registrationAsk{Merge: trunk.Commit}})
|
||||||
|
switch {
|
||||||
|
case err == nil, errors.Is(err, inventory.ErrSuperseded):
|
||||||
|
// Superseded is no failure: the module is already at what a later merge's key built, which a walk that carries
|
||||||
|
// this merge is answered by, as a build-agent outcome superseded was.
|
||||||
|
keepKeyWait(id, "")
|
||||||
|
keyTakes.Lock()
|
||||||
|
keyTakes.taken[id] = true
|
||||||
|
keyTakes.Unlock()
|
||||||
|
case errors.Is(err, errKeyWaits):
|
||||||
|
keepKeyWait(id, err.Error())
|
||||||
|
default:
|
||||||
|
why := err.Error()
|
||||||
|
if rerr := open.inventory.RecordRefused(ctx, id, module, why); rerr != nil {
|
||||||
|
fmt.Printf("plans: %s's refusal could not be kept in the store, only in this controller: %v\n", module, rerr)
|
||||||
|
}
|
||||||
|
keepRefusalFor(id, why, time.Now())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keepKeyWait(id, why string) {
|
||||||
|
keyTakes.Lock()
|
||||||
|
defer keyTakes.Unlock()
|
||||||
|
if why == "" {
|
||||||
|
delete(keyTakes.waits, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyTakes.waits[id] = why
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
|
"github.com/novox/mesh-controller/internal/builderkeys"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// planKeysOf is the indexer and the builder as a walk reads them, for a test: values by module and commit, keys by
|
||||||
|
// module.
|
||||||
|
type planKeysOf struct {
|
||||||
|
values map[string]sdkdelivery.Module // by module + "@" + commit
|
||||||
|
keys map[string]builderkeys.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k planKeysOf) ModuleAt(_ context.Context, module, commit string) (sdkdelivery.Module, error) {
|
||||||
|
if v, ok := k.values[module+"@"+commit]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return sdkdelivery.Module{}, builderkeys.ErrNoValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k planKeysOf) Build(_ context.Context, module, fp string) (builderkeys.Key, error) {
|
||||||
|
if key, ok := k.keys[module]; ok && key.Fingerprint == fp {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
return builderkeys.Key{Module: module, Fingerprint: fp}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordsOf is the build records and refusals a settle reads, for a test.
|
||||||
|
type recordsOf struct {
|
||||||
|
builds map[string]inventory.Build
|
||||||
|
refused map[string]string
|
||||||
|
sources map[string]inventory.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r recordsOf) BuildByID(_ context.Context, id string) (inventory.Build, bool, error) {
|
||||||
|
b, ok := r.builds[id]
|
||||||
|
return b, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r recordsOf) RefusedOf(_ context.Context, ids []string) (map[string]string, error) {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if why, ok := r.refused[id]; ok {
|
||||||
|
out[id] = why
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r recordsOf) SourceOf(_ context.Context, module string) (inventory.Source, error) {
|
||||||
|
if s, ok := r.sources[module]; ok {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return inventory.Source{}, inventory.ErrNoSuchModule
|
||||||
|
}
|
||||||
|
|
||||||
|
func aWalkOfOne(module string) (*inventory.Plan, map[string]inventory.Entry) {
|
||||||
|
p := &inventory.Plan{ID: "plan-1", State: inventory.PlanBuilding, Tiers: [][]string{{module}},
|
||||||
|
Modules: map[string]*inventory.PlanModule{}, Repository: "novox/mesh-catalog", Commit: "c0ffee0123456789",
|
||||||
|
Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{{Repository: "novox/mesh-catalog", Commit: "c0ffee0123456789"}}}}
|
||||||
|
byName := map[string]inventory.Entry{module: {Source: inventory.Source{Repository: "novox/mesh-catalog", Seat: "git",
|
||||||
|
Path: "modules/" + module, Ref: "main"}}}
|
||||||
|
return p, byName
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"]
|
||||||
|
if s.State != "asked" || !s.Key || s.Build != "" || s.KeyAt != "c0ffee0123456789" {
|
||||||
|
t.Fatalf("the module is not taken from a key: %+v", s)
|
||||||
|
}
|
||||||
|
fp := "src2:" + strings.Repeat("a", 64)
|
||||||
|
k := builderkeys.Key{Module: "sudo", Fingerprint: fp, Found: true, Revision: 2, Build: sdkdelivery.BuilderBuild{
|
||||||
|
State: sdkdelivery.BuildDone, Manifest: strings.Repeat("b", 64), Artifacts: []sdkdelivery.BuiltArtifact{{Name: "a",
|
||||||
|
Digest: "sha256:1", Reference: "artifact-store://sudo/a/blobs/sha256:1"}}}}
|
||||||
|
keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp, Commit: "c0ffee0123456789"}},
|
||||||
|
keys: map[string]builderkeys.Key{"sudo": k}}
|
||||||
|
records := recordsOf{builds: map[string]inventory.Build{}, sources: map[string]inventory.Source{}}
|
||||||
|
var taken []string
|
||||||
|
take := func(m string, _ sdkdelivery.Module, id string) { taken = append(taken, m+" "+id) }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
keyTakes.Lock()
|
||||||
|
keyTakes.running, keyTakes.waits, keyTakes.taken = map[string]bool{}, map[string]string{}, map[string]bool{}
|
||||||
|
keyTakes.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
changed, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take)
|
||||||
|
if err != nil || !changed || len(taken) != 1 || s.State != "asked" || s.Fingerprint != fp {
|
||||||
|
t.Fatalf("a done key not recorded yet is taken in once: %v %v %v %+v", changed, err, taken, s)
|
||||||
|
}
|
||||||
|
// A second look while the take-in runs starts no second one.
|
||||||
|
if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take); err != nil || len(taken) != 1 {
|
||||||
|
t.Fatalf("a second look took the key in again: %v %v", taken, err)
|
||||||
|
}
|
||||||
|
// The take-in recorded and registered it: the look settles it built from the record.
|
||||||
|
id := keyBuildID(k)
|
||||||
|
records.builds[id] = inventory.Build{ID: id, Module: "sudo", Commit: "c0ffee0123456789", At: time.Now()}
|
||||||
|
records.sources["sudo"] = inventory.Source{BuiltFrom: "c0ffee0123456789"}
|
||||||
|
if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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{}
|
||||||
|
keyTakes.Unlock()
|
||||||
|
})
|
||||||
|
p, byName := aWalkOfOne("view")
|
||||||
|
askModule(t.Context(), p, "view", byName)
|
||||||
|
s := p.Modules["view"]
|
||||||
|
records := recordsOf{builds: map[string]inventory.Build{}}
|
||||||
|
take := func(string, sdkdelivery.Module, string) {}
|
||||||
|
keys := planKeysOf{values: map[string]sdkdelivery.Module{}, keys: map[string]builderkeys.Key{}}
|
||||||
|
if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take); err != nil ||
|
||||||
|
!strings.Contains(s.Why, "waits for the indexer's value of view at c0ffee01") {
|
||||||
|
t.Fatalf("no indexer value yet: %q %v", s.Why, err)
|
||||||
|
}
|
||||||
|
fp := "src2:" + strings.Repeat("c", 64)
|
||||||
|
keys.values["view@c0ffee0123456789"] = sdkdelivery.Module{FP2: fp}
|
||||||
|
keys.keys["view"] = builderkeys.Key{Module: "view", Fingerprint: fp, Found: true,
|
||||||
|
Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildBuilding, By: "g14"}}
|
||||||
|
if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take); err != nil ||
|
||||||
|
s.State != "asked" || !strings.Contains(s.Why, "building on g14") || p.State != inventory.PlanBuilding {
|
||||||
|
t.Fatalf("a building key: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
keys.keys["view"] = builderkeys.Key{Module: "view", Fingerprint: fp, Found: true,
|
||||||
|
Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildFailed, Error: "not stored: tools: the store answered 500"}}
|
||||||
|
if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.State != "failed" || p.State != inventory.PlanFailed || !strings.Contains(p.Note, "not stored: tools") {
|
||||||
|
t.Fatalf("a failed key does not fail the walk in its words: %+v %q", s, p.Note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A7: a take-in refused (a module from another repository, a manifest refused) fails the walk with the refusal.
|
||||||
|
func TestARefusedTakeInFailsTheWalk(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()
|
||||||
|
})
|
||||||
|
p, byName := aWalkOfOne("sudo")
|
||||||
|
askModule(t.Context(), p, "sudo", byName)
|
||||||
|
fp := "src2:" + strings.Repeat("d", 64)
|
||||||
|
k := 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: "a",
|
||||||
|
Digest: "sha256:1", Reference: "artifact-store://sudo/a/blobs/sha256:1"}}}}
|
||||||
|
keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}},
|
||||||
|
keys: map[string]builderkeys.Key{"sudo": k}}
|
||||||
|
records := recordsOf{refused: map[string]string{keyBuildID(k): "sudo is built from novox/mesh-catalog, and this build is of agent/sudo"}}
|
||||||
|
if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], func(string, sdkdelivery.Module, string) {}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.State != inventory.PlanFailed || !strings.Contains(p.Note, "agent/sudo") {
|
||||||
|
t.Fatalf("a refused take-in did not fail the walk: %s %q", p.State, p.Note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A6: a walk's module whose fingerprint did not change since an earlier merge has its key recorded at that merge's
|
||||||
|
// commit: the walk takes it in again, so the module is registered at the commit the walk carries, before it is built.
|
||||||
|
func TestAKeyRecordedAtAnEarlierCommitIsTakenInAgain(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()
|
||||||
|
})
|
||||||
|
p, byName := aWalkOfOne("sudo")
|
||||||
|
askModule(t.Context(), p, "sudo", byName)
|
||||||
|
fp := "src2:" + strings.Repeat("e", 64)
|
||||||
|
k := 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: "a",
|
||||||
|
Digest: "sha256:1", Reference: "artifact-store://sudo/a/blobs/sha256:1"}}}}
|
||||||
|
keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp, Commit: "c0ffee0123456789"}},
|
||||||
|
keys: map[string]builderkeys.Key{"sudo": k}}
|
||||||
|
id := keyBuildID(k)
|
||||||
|
records := recordsOf{builds: map[string]inventory.Build{id: {ID: id, Module: "sudo", Commit: "0ld0ld0ld0ld"}},
|
||||||
|
sources: map[string]inventory.Source{"sudo": {BuiltFrom: "0ld0ld0ld0ld"}}}
|
||||||
|
var taken []string
|
||||||
|
if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], func(m string, _ sdkdelivery.Module, _ string) {
|
||||||
|
taken = append(taken, m)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s := p.Modules["sudo"]; s.State != "asked" || len(taken) != 1 {
|
||||||
|
t.Fatalf("a key recorded at an earlier commit settled the walk at that commit: %+v, taken %v", s, taken)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,7 +192,7 @@ func startRolloutEvents(ctx context.Context, open *stores, js *broker.JetStream)
|
|||||||
if err := startProvAppliedLooks(ctx, js); err != nil {
|
if err := startProvAppliedLooks(ctx, js); err != nil {
|
||||||
fmt.Printf("plans: the provisioner clients' words could not be watched (%v); a walk waits for them until the next look\n", err)
|
fmt.Printf("plans: the provisioner clients' words could not be watched (%v); a walk waits for them until the next look\n", err)
|
||||||
}
|
}
|
||||||
if err := startPlanMovedLooks(ctx, js); err != nil {
|
if err := startPlanMovedLooks(ctx, js, open); err != nil {
|
||||||
fmt.Printf("plans: the plans moved by other processes could not be followed (%v)\n", err)
|
fmt.Printf("plans: the plans moved by other processes could not be followed (%v)\n", err)
|
||||||
}
|
}
|
||||||
done := startPlanLooks(ctx, open)
|
done := startPlanLooks(ctx, open)
|
||||||
@@ -563,7 +563,7 @@ func startProvAppliedLooks(ctx context.Context, js *broker.JetStream) error {
|
|||||||
// `plan-moved`, so a plan a person moved with a verb at the control node's terminal — a process this one did not spawn,
|
// `plan-moved`, so a plan a person moved with a verb at the control node's terminal — a process this one did not spawn,
|
||||||
// whose saves it does not hear — is looked at all the same (novox/hq ADR 0406 §2, §5, issue 974). New messages only; the
|
// whose saves it does not hear — is looked at all the same (novox/hq ADR 0406 §2, §5, issue 974). New messages only; the
|
||||||
// first look reads the rest. A look its own save asks is the same look: asks are one.
|
// first look reads the rest. A look its own save asks is the same look: asks are one.
|
||||||
func startPlanMovedLooks(ctx context.Context, js *broker.JetStream) error {
|
func startPlanMovedLooks(ctx context.Context, js *broker.JetStream, open *stores) error {
|
||||||
api, err := jetstream.New(js.Conn())
|
api, err := jetstream.New(js.Conn())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -578,7 +578,18 @@ func startPlanMovedLooks(ctx context.Context, js *broker.JetStream) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("following the plans moved: %w", err)
|
return fmt.Errorf("following the plans moved: %w", err)
|
||||||
}
|
}
|
||||||
consuming, err := consumer.Consume(func(jetstream.Msg) { askToLook() })
|
var bus link.Bus
|
||||||
|
if core, err := js.Conn().JetStream(); err == nil {
|
||||||
|
bus = link.OverNATS{Conn: js.Conn(), JS: core}
|
||||||
|
}
|
||||||
|
consuming, err := consumer.Consume(func(m jetstream.Msg) {
|
||||||
|
askToLook()
|
||||||
|
if bus != nil && open != nil {
|
||||||
|
// A walk's start or end, said by this controller or a verb: said again with the machines whose
|
||||||
|
// declaration it changes (novox/hq ADR 0505 §2, walk_declared.go).
|
||||||
|
go sayDeclared(context.WithoutCancel(ctx), bus, open, m.Data())
|
||||||
|
}
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("following the plans moved: %w", err)
|
return fmt.Errorf("following the plans moved: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func TestAPlanMovedByAnotherProcessAsksForALook(t *testing.T) {
|
|||||||
ctx, stop := context.WithCancel(context.Background())
|
ctx, stop := context.WithCancel(context.Background())
|
||||||
defer stop()
|
defer stop()
|
||||||
go l.run(ctx)
|
go l.run(ctx)
|
||||||
if err := startPlanMovedLooks(ctx, js); err != nil {
|
if err := startPlanMovedLooks(ctx, js, nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
legacy, err := js.Conn().JetStream()
|
legacy, err := js.Conn().JetStream()
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ func TestNoPlanIsWrittenButThroughSavePlansMoving(t *testing.T) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
text := string(raw)
|
text := string(raw)
|
||||||
if write.MatchString(text) {
|
// The regular expression is the costly part of reading every source: only a file that names the table can match.
|
||||||
|
if strings.Contains(strings.ToLower(text), "release_plan") && write.MatchString(text) {
|
||||||
found = append(found, path+" writes the plan table")
|
found = append(found, path+" writes the plan table")
|
||||||
}
|
}
|
||||||
// A plan record is written by the bucket's raw create and update, which only the plans' own file calls.
|
// A plan record is written by the bucket's raw create and update, which only the plans' own file calls.
|
||||||
|
|||||||
@@ -402,11 +402,13 @@ func retryPlan(ctx context.Context, open *stores, id string) (string, error) {
|
|||||||
var asked []string
|
var asked []string
|
||||||
for _, m := range failed {
|
for _, m := range failed {
|
||||||
askModule(ctx, &p, m, byName)
|
askModule(ctx, &p, m, byName)
|
||||||
if s := p.Modules[m]; s.State == "asked" {
|
if s := p.Modules[m]; s.State == "asked" && s.Key {
|
||||||
|
asked = append(asked, m+" from the builder's key")
|
||||||
|
} else if s.State == "asked" {
|
||||||
asked = append(asked, m+" as "+s.Build)
|
asked = append(asked, m+" as "+s.Build)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
waiting := busWaitingIn(p)
|
waiting := busWaitingOnRetry(p)
|
||||||
note, said := retryWords(p, failed, asked, waiting)
|
note, said := retryWords(p, failed, asked, waiting)
|
||||||
resumed(&p, note)
|
resumed(&p, note)
|
||||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
@@ -418,6 +420,14 @@ func retryPlan(ctx context.Context, open *stores, id string) (string, error) {
|
|||||||
return said, nil
|
return said, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// busWaitingOnRetry are the modules a retry left waiting for the bus. Read before the plan is resumed, while it still says
|
||||||
|
// failed, so read as the plan the retry resumes: a failed plan is not open, and busWaitingIn would name none, leaving the
|
||||||
|
// answer "asked" with nothing after it (issue 1403).
|
||||||
|
func busWaitingOnRetry(p inventory.Plan) []string {
|
||||||
|
p.State = inventory.PlanBuilding
|
||||||
|
return busWaitingIn(p)
|
||||||
|
}
|
||||||
|
|
||||||
// retryWords are the plan's note and the verb's answer for a retry: what was asked again (as the builds it got), and what waits
|
// retryWords are the plan's note and the verb's answer for a retry: what was asked again (as the builds it got), and what waits
|
||||||
// for the bus. A retry whose modules all wait for the bus asked none, and says so rather than "asked" with nothing after it
|
// for the bus. A retry whose modules all wait for the bus asked none, and says so rather than "asked" with nothing after it
|
||||||
// (issue 1403).
|
// (issue 1403).
|
||||||
@@ -440,101 +450,6 @@ func retryWords(p inventory.Plan, failed, asked, waiting []string) (note, said s
|
|||||||
return note, said
|
return note, said
|
||||||
}
|
}
|
||||||
|
|
||||||
// joinAPlan asks a module again for the plan that holds it unbuilt or failed, if one does: open plans
|
|
||||||
// first, then the most recent failed one that nothing newer supersedes. Says whether it joined one.
|
|
||||||
func joinAPlan(ctx context.Context, open *stores, module string) (bool, string, error) {
|
|
||||||
inv := open.inventory
|
|
||||||
release, err := inv.HoldPlans(ctx, true)
|
|
||||||
if err != nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
openPlans, err := inv.OpenPlans(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
recent, err := inv.RecentPlans(ctx, 20)
|
|
||||||
if err != nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
p, found := planHolding(module, openPlans, recent)
|
|
||||||
if !found {
|
|
||||||
return false, "", nil
|
|
||||||
}
|
|
||||||
// **A walk that waits for its delivery's word asks nothing** (novox/hq ADR 0239, issue 754): a rebuild does not
|
|
||||||
// join it, and its outcome is not the walk's. The walk asks its own tier once it has its word.
|
|
||||||
if p.Waiting() {
|
|
||||||
return false, fmt.Sprintf("not joining %s (%s at %s): it waits for %s's word, so this build is not its "+
|
|
||||||
"outcome; once let go, the walk asks %s itself", p.ID, p.Repository, short(p.Commit), p.Delivery.Awaits,
|
|
||||||
module), nil
|
|
||||||
}
|
|
||||||
entries, err := inv.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
byName := map[string]inventory.Entry{}
|
|
||||||
for _, e := range entries {
|
|
||||||
byName[e.Manifest.Module] = e
|
|
||||||
}
|
|
||||||
was := p.State
|
|
||||||
askModule(ctx, &p, module, byName)
|
|
||||||
s := p.Modules[module]
|
|
||||||
resumed(&p, fmt.Sprintf("tier %d: %s rebuilt by hand", p.Tier, module))
|
|
||||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
if s.State == "" && s.BusWaitFrom != nil {
|
|
||||||
return true, busWaitWords(p, []string{module}), nil
|
|
||||||
}
|
|
||||||
if s.State != "asked" {
|
|
||||||
return true, "", fmt.Errorf("%s could not be asked for %s: %s", module, p.ID, s.Why)
|
|
||||||
}
|
|
||||||
said := fmt.Sprintf("rebuild asked as %s, joining %s at tier %d (%s at %s): the plan takes this build as %s's outcome",
|
|
||||||
s.Build, p.ID, p.Tier, p.Repository, short(p.Commit), module)
|
|
||||||
switch {
|
|
||||||
case was == inventory.PlanFailed && p.State == inventory.PlanBuilding:
|
|
||||||
said += "; the plan had failed and builds again from this tier"
|
|
||||||
case was == inventory.PlanFailed:
|
|
||||||
said += "; the plan stays failed while " + strings.Join(failedIn(p), ", ") + " failed too — `plans retry " + p.ID + "` asks them"
|
|
||||||
}
|
|
||||||
return true, said, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planHolding is the plan a rebuild of a module joins: an open plan whose current tier holds it not
|
|
||||||
// yet built, else the newest failed plan whose current tier does, and that no open plan of its
|
|
||||||
// repository supersedes.
|
|
||||||
func planHolding(module string, openPlans, recent []inventory.Plan) (inventory.Plan, bool) {
|
|
||||||
holds := func(p inventory.Plan) bool {
|
|
||||||
if p.Tier >= len(p.Tiers) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, m := range p.Tiers[p.Tier] {
|
|
||||||
if m == module {
|
|
||||||
s := p.Modules[m]
|
|
||||||
return s == nil || s.State != "built"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, p := range openPlans {
|
|
||||||
if holds(p) {
|
|
||||||
return p, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sorted := append([]inventory.Plan(nil), recent...)
|
|
||||||
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Created.After(sorted[j].Created) })
|
|
||||||
for _, p := range sorted {
|
|
||||||
if p.State != inventory.PlanFailed || !holds(p) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, superseded := newerOpenPlan(p, openPlans); superseded {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return p, true
|
|
||||||
}
|
|
||||||
return inventory.Plan{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// retryRollouts sends each module whose rollout stopped to the machines it was first sent to, again,
|
// retryRollouts sends each module whose rollout stopped to the machines it was first sent to, again,
|
||||||
// records that send as the first anew, and sets the plan rolling: from there it goes on as the plan
|
// records that send as the first anew, and sets the plan rolling: from there it goes on as the plan
|
||||||
// would have — the rest sent once those report they applied it, the next tier after (ADR 0218).
|
// would have — the rest sent once those report they applied it, the next tier after (ADR 0218).
|
||||||
@@ -629,7 +544,9 @@ func retryTierWhole(ctx context.Context, open *stores, p *inventory.Plan, again
|
|||||||
for _, m := range again {
|
for _, m := range again {
|
||||||
sendAgain(p.Modules[m])
|
sendAgain(p.Modules[m])
|
||||||
askModule(ctx, p, m, byName)
|
askModule(ctx, p, m, byName)
|
||||||
if s := p.Modules[m]; s.State == "asked" {
|
if s := p.Modules[m]; s.State == "asked" && s.Key {
|
||||||
|
asked = append(asked, m+" from the builder's key")
|
||||||
|
} else if s.State == "asked" {
|
||||||
asked = append(asked, m+" as "+s.Build)
|
asked = append(asked, m+" as "+s.Build)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
|||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"cmd/mesh-builder/", "internal/broker/"}, gomod...)}}
|
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"cmd/mesh-builder/", "internal/broker/"}, gomod...)}}
|
||||||
sourcesOf["route-proxy"] = []inventory.BuildSource{
|
sourcesOf["route-proxy"] = []inventory.BuildSource{
|
||||||
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"examples/route-proxy/", "internal/broker/"}, gomod...)}}
|
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"modules/route-proxy/cmd/route-proxy/", "internal/broker/"}, gomod...)}}
|
||||||
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
|
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
|
||||||
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
|
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
|
||||||
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
|
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
|
||||||
@@ -133,7 +133,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
|||||||
{"a package only the controller builds from: the controller alone", "mesh-controller",
|
{"a package only the controller builds from: the controller alone", "mesh-controller",
|
||||||
[]string{"internal/conditions/condition.go"}, "mesh-controller", true},
|
[]string{"internal/conditions/condition.go"}, "mesh-controller", true},
|
||||||
{"the route proxy's program: the route proxy alone", "mesh-controller",
|
{"the route proxy's program: the route proxy alone", "mesh-controller",
|
||||||
[]string{"examples/route-proxy/main.go"}, "route-proxy", true},
|
[]string{"modules/route-proxy/cmd/route-proxy/main.go"}, "route-proxy", true},
|
||||||
{"a package all three build from: all three", "mesh-controller",
|
{"a package all three build from: all three", "mesh-controller",
|
||||||
[]string{"internal/broker/broker.go"}, "mesh-controller,route-proxy | build-agent", false},
|
[]string{"internal/broker/broker.go"}, "mesh-controller,route-proxy | build-agent", false},
|
||||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
|||||||
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
|
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
|
||||||
},
|
},
|
||||||
"route-proxy": {
|
"route-proxy": {
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("examples/route-proxy/", "internal/broker/",
|
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("modules/route-proxy/cmd/route-proxy/", "internal/broker/",
|
||||||
"vendor/github.com/nats-io/nats.go/")},
|
"vendor/github.com/nats-io/nats.go/")},
|
||||||
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
||||||
},
|
},
|
||||||
@@ -288,7 +288,7 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
|||||||
{"a README of the controller's repository: no module (338)", "mesh-controller",
|
{"a README of the controller's repository: no module (338)", "mesh-controller",
|
||||||
[]string{"README.md"}, read, "", "README.md"},
|
[]string{"README.md"}, read, "", "README.md"},
|
||||||
{"the route proxy's program alone: the route proxy alone (338)", "mesh-controller",
|
{"the route proxy's program alone: the route proxy alone (338)", "mesh-controller",
|
||||||
[]string{"examples/route-proxy/main.go"}, read, "route-proxy", ""},
|
[]string{"modules/route-proxy/cmd/route-proxy/main.go"}, read, "route-proxy", ""},
|
||||||
{"the build seat's program alone: its holder alone", "mesh-controller",
|
{"the build seat's program alone: its holder alone", "mesh-controller",
|
||||||
[]string{"cmd/mesh-builder/main.go"}, read, "build-agent", ""},
|
[]string{"cmd/mesh-builder/main.go"}, read, "build-agent", ""},
|
||||||
{"a package the build seat's program and the controller build from: both", "mesh-controller",
|
{"a package the build seat's program and the controller build from: both", "mesh-controller",
|
||||||
@@ -305,7 +305,7 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
|||||||
{"go.sum: all three", "mesh-controller",
|
{"go.sum: all three", "mesh-controller",
|
||||||
[]string{"go.sum"}, read, "mesh-controller,route-proxy | build-agent", ""},
|
[]string{"go.sum"}, read, "mesh-controller,route-proxy | build-agent", ""},
|
||||||
{"a file added to the proxy's package: the proxy", "mesh-controller",
|
{"a file added to the proxy's package: the proxy", "mesh-controller",
|
||||||
[]string{"examples/route-proxy/new.go"}, read, "route-proxy", ""},
|
[]string{"modules/route-proxy/cmd/route-proxy/new.go"}, read, "route-proxy", ""},
|
||||||
// Before any build source is said: as before.
|
// Before any build source is said: as before.
|
||||||
{"no build source said: a README moves all three, as before", "mesh-controller",
|
{"no build source said: a README moves all three, as before", "mesh-controller",
|
||||||
[]string{"README.md"}, unsaid, "mesh-controller,route-proxy | build-agent", ""},
|
[]string{"README.md"}, unsaid, "mesh-controller,route-proxy | build-agent", ""},
|
||||||
@@ -345,7 +345,7 @@ func TestAModuleAPlanOvertookIsReadWhole(t *testing.T) {
|
|||||||
built := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
|
built := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
|
||||||
read := map[string][]inventory.ReadRepository{
|
read := map[string][]inventory.ReadRepository{
|
||||||
"route-proxy": {
|
"route-proxy": {
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}, Built: built},
|
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"modules/route-proxy/cmd/route-proxy/", "go.mod"}, Built: built},
|
||||||
{Own: true, Paths: []string{"modules/route-proxy/module.json"}, Built: built},
|
{Own: true, Paths: []string{"modules/route-proxy/module.json"}, Built: built},
|
||||||
},
|
},
|
||||||
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: built}},
|
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: built}},
|
||||||
@@ -396,11 +396,11 @@ func TestAMissedMergeMovingOnlyAPackagingModuleIsActedOnOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
read := map[string][]inventory.ReadRepository{
|
read := map[string][]inventory.ReadRepository{
|
||||||
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: merged.Add(-time.Hour)}},
|
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: merged.Add(-time.Hour)}},
|
||||||
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/"},
|
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"modules/route-proxy/cmd/route-proxy/"},
|
||||||
Built: merged.Add(-time.Hour), Looked: merged.Add(-time.Hour)}},
|
Built: merged.Add(-time.Hour), Looked: merged.Add(-time.Hour)}},
|
||||||
}
|
}
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "c1",
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "c1",
|
||||||
MergedAt: merged.Format(time.RFC3339), Paths: []string{"examples/route-proxy/main.go"}}
|
MergedAt: merged.Format(time.RFC3339), Paths: []string{"modules/route-proxy/cmd/route-proxy/main.go"}}
|
||||||
if got := wouldMove(m, entries, planningView(read, nil)); len(got) != 1 || got[0].Manifest.Module != "route-proxy" {
|
if got := wouldMove(m, entries, planningView(read, nil)); len(got) != 1 || got[0].Manifest.Module != "route-proxy" {
|
||||||
t.Fatalf("a missed merge of the proxy's program would move %v", got)
|
t.Fatalf("a missed merge of the proxy's program would move %v", got)
|
||||||
}
|
}
|
||||||
@@ -599,14 +599,14 @@ func TestTheGatePlansFromTheBuildSourcesTheSnapshotCarries(t *testing.T) {
|
|||||||
Sources: []snapshot.BuildSource{{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/broker/"}}}},
|
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"),
|
{Name: "route-proxy", Repository: "novox/mesh-catalog", Path: "modules/route-proxy", Manifest: manifest("route-proxy"),
|
||||||
Reads: []string{"novox/mesh-controller"},
|
Reads: []string{"novox/mesh-controller"},
|
||||||
Sources: []snapshot.BuildSource{{Repository: "novox/mesh-controller", Paths: []string{"examples/route-proxy/", "internal/broker/"}},
|
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"}}}},
|
{Own: true, Paths: []string{"modules/route-proxy/module.json"}}}},
|
||||||
}}
|
}}
|
||||||
for paths, want := range map[string]string{
|
for paths, want := range map[string]string{
|
||||||
"cmd/mesh-controller/main.go": "mesh-controller",
|
"cmd/mesh-controller/main.go": "mesh-controller",
|
||||||
"examples/route-proxy/main.go": "route-proxy",
|
"modules/route-proxy/cmd/route-proxy/main.go": "route-proxy",
|
||||||
"internal/broker/broker.go": "mesh-controller,route-proxy",
|
"internal/broker/broker.go": "mesh-controller,route-proxy",
|
||||||
"README.md": "",
|
"README.md": "",
|
||||||
} {
|
} {
|
||||||
r, err := reachOfChange(facts, "novox/mesh-controller", []string{paths}, "")
|
r, err := reachOfChange(facts, "novox/mesh-controller", []string{paths}, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func TestASavedPlanPutsOrDeletesItsKey(t *testing.T) {
|
|||||||
|
|
||||||
// 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.
|
// 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) {
|
func TestThePlansKeyIsPutBeforeItsFirstBuildIsAsked(t *testing.T) {
|
||||||
|
asksOfEveryModule(t)
|
||||||
store := newFakeBuildingStore()
|
store := newFakeBuildingStore()
|
||||||
var s buildingStore = store
|
var s buildingStore = store
|
||||||
buildingKeys.Store(&s)
|
buildingKeys.Store(&s)
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// flagsOf reads a command line's flags (and its first positional after --what-if) into a map.
|
// flagsOf reads a command line's flags (and its first positional after --what-if) into a map.
|
||||||
@@ -28,74 +25,6 @@ func flagsOf(argv []string) map[string]string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The plan of a change as data from `plans`** (novox/hq ADR 0450 §2, issue 1324 AC1): asked for one change
|
|
||||||
// both ways, `plans` with data and `delivery-plan` answer the same plan in every field.
|
|
||||||
func TestPlansWhatIfAsDataAnswersWhatDeliveryPlanAnswers(t *testing.T) {
|
|
||||||
change := map[string]any{"repository": "novox/mesh-catalog", "paths": "modules/gitea/index.ts,modules/gitea/old.txt",
|
|
||||||
"head": "abc123", "base": "main", "module-dirs": "modules/gitea", "removed": "modules/gitea/old.txt"}
|
|
||||||
with := func(extra map[string]any) map[string]any {
|
|
||||||
out := map[string]any{}
|
|
||||||
for k, v := range change {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
for k, v := range extra {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
plansArgv, err := argvFor("plans", with(map[string]any{"data": "true"}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
deliveryArgv, err := argvFor("delivery-plan", change)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if plansArgv[0] != "plans" || !slicesHas(plansArgv, "--data") || !slicesHas(plansArgv, "--what-if") {
|
|
||||||
t.Fatalf("plans with data became %q", strings.Join(plansArgv, " "))
|
|
||||||
}
|
|
||||||
p, d := flagsOf(plansArgv), flagsOf(deliveryArgv)
|
|
||||||
for _, name := range []string{"paths", "head", "base", "module-dirs", "removed"} {
|
|
||||||
if p[name] == "" || p[name] != d[name] {
|
|
||||||
t.Errorf("--%s: plans says %q, delivery-plan %q", name, p[name], d[name])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p["what-if"] != d["repository"] {
|
|
||||||
t.Errorf("repository: plans says %q, delivery-plan %q", p["what-if"], d["repository"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both commands build their document from the one function, from the same member.
|
|
||||||
const src = "http://forge.internal:20000/novox/mesh-catalog.git"
|
|
||||||
gitea := fromRepo("gitea", src, "modules/gitea")
|
|
||||||
gitea.Source.BuiltFrom = "old"
|
|
||||||
gitea.On = []string{"anchor", "laptop"}
|
|
||||||
entries := []inventory.Entry{gitea}
|
|
||||||
policy := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true }
|
|
||||||
member := func(f map[string]string, repository string) orderMember {
|
|
||||||
return changeMember(repository, f["base"], f["head"], splitList(f["paths"]), splitList(f["removed"]),
|
|
||||||
moduleDirsOf(f["module-dirs"]))
|
|
||||||
}
|
|
||||||
a := deliveryPlanDocument(member(p, p["what-if"]), entries, nil, nil, policy)
|
|
||||||
b := deliveryPlanDocument(member(d, d["repository"]), entries, nil, nil, policy)
|
|
||||||
ja, _ := json.Marshal(a)
|
|
||||||
jb, _ := json.Marshal(b)
|
|
||||||
if !reflect.DeepEqual(ja, jb) {
|
|
||||||
t.Errorf("the two plans differ:\n%s\n%s", ja, jb)
|
|
||||||
}
|
|
||||||
plan := a["plan"].(interface{})
|
|
||||||
raw, _ := json.Marshal(plan)
|
|
||||||
var fields map[string]any
|
|
||||||
_ = json.Unmarshal(raw, &fields)
|
|
||||||
for _, want := range []string{"moved", "tiers", "machines", "summary", "class", "head", "base", "repository"} {
|
|
||||||
if _, ok := fields[want]; !ok {
|
|
||||||
t.Errorf("the plan has no %q: %s", want, raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fields["head"] != "abc123" || fields["base"] != "main" {
|
|
||||||
t.Errorf("head and base are %v and %v", fields["head"], fields["base"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without the data form the text answer is unchanged (AC2): no data flag, and the data-only arguments are not passed.
|
// Without the data form the text answer is unchanged (AC2): no data flag, and the data-only arguments are not passed.
|
||||||
func TestPlansWhatIfWithoutDataIsUnchanged(t *testing.T) {
|
func TestPlansWhatIfWithoutDataIsUnchanged(t *testing.T) {
|
||||||
argv, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a,b"})
|
argv, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a,b"})
|
||||||
@@ -114,20 +43,6 @@ func TestPlansWhatIfWithoutDataIsUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// delivery-plan stays and answers as before (AC3, ADR 0450 §8).
|
|
||||||
func TestDeliveryPlanStaysAsItWas(t *testing.T) {
|
|
||||||
argv, err := argvFor("delivery-plan", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := strings.Join(argv, " "); got != "delivery plan --repository novox/x --paths a --head h" {
|
|
||||||
t.Errorf("delivery-plan reads %q", got)
|
|
||||||
}
|
|
||||||
if _, ok := catalogueVerb("delivery-plan"); !ok {
|
|
||||||
t.Error("delivery-plan is gone")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func catalogueVerb(name string) (catalogue.Verb, bool) { return controllerVerb(name) }
|
func catalogueVerb(name string) (catalogue.Verb, bool) { return controllerVerb(name) }
|
||||||
|
|
||||||
func slicesHas(s []string, v string) bool {
|
func slicesHas(s []string, v string) bool {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ func planWaitCommand(ctx context.Context, inv *inventory.Inventory, id, untilAsk
|
|||||||
line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string {
|
line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string {
|
||||||
on, _ := inv.Running(ctx, module)
|
on, _ := inv.Running(ctx, module)
|
||||||
return on
|
return on
|
||||||
}, waitsOf(ctx, inv, one))
|
}, 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, pause, bounds)[0], until, came, now.Sub(began), line)
|
||||||
return json.NewEncoder(os.Stdout).Encode(doc)
|
return json.NewEncoder(os.Stdout).Encode(doc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the controller tells mesh-config of an open pull request, and asks it back (novox/hq ADR 0502 clauses 2 and 4,
|
||||||
|
// issue 1826): a config key a pull request introduces is not declared on the trunk, so a value for it can be judged
|
||||||
|
// only against the pull request's own declaration, which the core reads from the forge and mesh-config never does
|
||||||
|
// (ADR 0495 §1). The call is mesh-config's `config` verb in the controller's own name, actions `pull-declares` and
|
||||||
|
// `held`; the shapes are those of the interface the two sides agreed.
|
||||||
|
|
||||||
|
// meshConfigWait is how long the controller waits for mesh-config before it goes on without: a check is never held up
|
||||||
|
// by a module that does not answer.
|
||||||
|
const meshConfigWait = 10 * time.Second
|
||||||
|
|
||||||
|
// meshConfigAsk is one call of mesh-config's `config` verb in the controller's own name, answering its result. A
|
||||||
|
// variable so a test needs no bus.
|
||||||
|
var meshConfigAsk = func(ctx context.Context, args map[string]any) ([]byte, error) {
|
||||||
|
var result []byte
|
||||||
|
err := onTheBus(func(conn *nats.Conn) error {
|
||||||
|
answer, err := link.AskMeshSeatToolAs(ctx, conn, broker.ControllerName, "mesh-config", "config", args, meshConfigWait)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if answer.Error != "" {
|
||||||
|
return fmt.Errorf("%s", answer.Error)
|
||||||
|
}
|
||||||
|
result = answer.Result
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// pullAt is an open pull request at a head: the change to read, and its number.
|
||||||
|
type pullAt struct {
|
||||||
|
changeSource
|
||||||
|
Number int
|
||||||
|
}
|
||||||
|
|
||||||
|
// pullAtOf is a pull request announced, as the change it makes over what it merges into.
|
||||||
|
func pullAtOf(p link.PullUpdated) pullAt {
|
||||||
|
old := p.BaseSHA
|
||||||
|
if old == "" {
|
||||||
|
old = p.Base
|
||||||
|
}
|
||||||
|
return pullAt{Number: p.Number, changeSource: changeSource{Owner: p.Owner, Repo: p.Repo, New: p.Commit, Old: old,
|
||||||
|
Paths: p.Paths, Truncated: p.PathsTruncated, Removed: p.Removed}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pullKey(owner, repo string, number int) string {
|
||||||
|
return fmt.Sprintf("%s/%s#%d", owner, repo, number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p pullAt) key() string { return pullKey(p.Owner, p.Repo, p.Number) }
|
||||||
|
|
||||||
|
// pullDeclarations are the config declarations a pull request's head adds or changes over what it merges into, by
|
||||||
|
// module, in the shape of the declared-config mirror. The bool says the head could be read: false for a pull request whose
|
||||||
|
// paths are cut (the caller cuts them to the manifests of the modules it moves) or whose base is unknown, which is then
|
||||||
|
// told nothing, never an empty set.
|
||||||
|
func pullDeclarations(ctx context.Context, p pullAt) ([]inventory.DeclaredConfigMirrored, bool, error) {
|
||||||
|
var out []inventory.DeclaredConfigMirrored
|
||||||
|
if p.Truncated || p.Old == "" {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
for _, file := range p.Paths {
|
||||||
|
if path.Base(file) != moduleManifestFile || slices.Contains(p.Removed, file) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now, exists, err := manifestCached(ctx, p.Owner, p.Repo, file, p.New)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if !exists || now.Module == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
was, _, err := manifestCached(ctx, p.Owner, p.Repo, file, p.Old)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
have, err := inventory.DeclaredOf(now.Settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("%s at %.8s: %w", file, p.New, err)
|
||||||
|
}
|
||||||
|
had, err := inventory.DeclaredOf(was.Settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("%s at %.8s: %w", file, p.Old, err)
|
||||||
|
}
|
||||||
|
changed := map[string]inventory.DeclaredMirrored{}
|
||||||
|
for key, d := range have {
|
||||||
|
if before, there := had[key]; !there || !reflect.DeepEqual(before, d) {
|
||||||
|
changed[key] = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(changed) > 0 {
|
||||||
|
out = append(out, inventory.DeclaredConfigMirrored{Module: now.Module, Settings: changed})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tellPullDeclares tells mesh-config, for each pull request at every announced head, what the head declares, an empty set
|
||||||
|
// when it declares nothing new: mesh-config re-judges the values held at every head, so a head that reverts the change
|
||||||
|
// drops them (ADR 0502 clause 6). A
|
||||||
|
// failure is said to w and the check goes on: the held values are then none (ADR 0502 clause 2).
|
||||||
|
func tellPullDeclares(ctx context.Context, w io.Writer, pulls []pullAt) {
|
||||||
|
for _, p := range pulls {
|
||||||
|
mods, readable, err := pullDeclarations(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "%s: what its head declares cannot be read, so mesh-config is told nothing: %v\n", p.key(), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !readable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
declares := map[string]map[string]inventory.DeclaredMirrored{}
|
||||||
|
for _, m := range mods {
|
||||||
|
declares[m.Module] = m.Settings
|
||||||
|
}
|
||||||
|
if _, err := meshConfigAsk(ctx, map[string]any{"action": "pull-declares", "pull": p.key(), "head": p.New,
|
||||||
|
"declares": declares}); err != nil {
|
||||||
|
fmt.Fprintf(w, "%s: mesh-config cannot be told what its head declares (%v); the check goes on without held values\n",
|
||||||
|
p.key(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsModules is whether a module of the mesh is built from this repository.
|
||||||
|
func holdsModules(entries []inventory.Entry, owner, repo string) bool {
|
||||||
|
return slices.ContainsFunc(entries, func(e inventory.Entry) bool {
|
||||||
|
return sameRepository(e.Source.Repository, link.SourceMoved{Owner: owner, Repo: repo})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
headSHA = "2222222222222222222222222222222222222222"
|
||||||
|
baseSHA = "1111111111111111111111111111111111111111"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeManifests serves manifests by ref, and counts what the forge was asked. Issue 1826/AC1 (unit level).
|
||||||
|
func fakeManifests(t *testing.T, at map[string]*catalogue.Manifest) {
|
||||||
|
t.Helper()
|
||||||
|
was := manifestAt
|
||||||
|
t.Cleanup(func() { manifestAt = was })
|
||||||
|
manifestsReadMu.Lock()
|
||||||
|
clear(manifestsRead)
|
||||||
|
manifestsReadMu.Unlock()
|
||||||
|
manifestAt = func(_ context.Context, _, _, _, ref string) (catalogue.Manifest, bool, error) {
|
||||||
|
if m := at[ref]; m != nil {
|
||||||
|
return *m, true, nil
|
||||||
|
}
|
||||||
|
return catalogue.Manifest{}, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeConfig records the calls to mesh-config and answers each action from answers.
|
||||||
|
func fakeConfig(t *testing.T, answers map[string]string) *[]map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var calls []map[string]any
|
||||||
|
was := meshConfigAsk
|
||||||
|
t.Cleanup(func() { meshConfigAsk = was })
|
||||||
|
meshConfigAsk = func(_ context.Context, args map[string]any) ([]byte, error) {
|
||||||
|
calls = append(calls, args)
|
||||||
|
return []byte(answers[args["action"].(string)]), nil
|
||||||
|
}
|
||||||
|
return &calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifest(module string, keys map[string]catalogue.SettingDeclaration) *catalogue.Manifest {
|
||||||
|
return &catalogue.Manifest{Module: module, Settings: keys}
|
||||||
|
}
|
||||||
|
|
||||||
|
func thePull() pullAt {
|
||||||
|
return pullAt{Number: 434, changeSource: changeSource{Owner: "novox", Repo: "mesh-catalog", New: headSHA, Old: baseSHA,
|
||||||
|
Paths: []string{"modules/n8n/module.json", "modules/n8n/README.md"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC1: a key the head adds is sent with its declaration, under the pull request and the head.
|
||||||
|
func TestAKeyThePullRequestAddsIsToldToMeshConfig(t *testing.T) {
|
||||||
|
fakeManifests(t, map[string]*catalogue.Manifest{
|
||||||
|
baseSHA: manifest("n8n", map[string]catalogue.SettingDeclaration{"port": {Type: "number"}}),
|
||||||
|
headSHA: manifest("n8n", map[string]catalogue.SettingDeclaration{"port": {Type: "number"},
|
||||||
|
"media-gid": {Type: "number", Required: true}}),
|
||||||
|
})
|
||||||
|
calls := fakeConfig(t, map[string]string{"pull-declares": `{"kept":1}`})
|
||||||
|
tellPullDeclares(context.Background(), io.Discard, []pullAt{thePull()})
|
||||||
|
if len(*calls) != 1 {
|
||||||
|
t.Fatalf("mesh-config was asked %d times: %v", len(*calls), *calls)
|
||||||
|
}
|
||||||
|
c := (*calls)[0]
|
||||||
|
if c["action"] != "pull-declares" || c["pull"] != "novox/mesh-catalog#434" || c["head"] != headSHA {
|
||||||
|
t.Fatalf("the call is %v", c)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(c["declares"])
|
||||||
|
var declares map[string]map[string]inventory.DeclaredMirrored
|
||||||
|
if err := json.Unmarshal(body, &declares); err != nil || len(declares) != 1 || declares["n8n"] == nil {
|
||||||
|
t.Fatalf("declares %s: %v", body, err)
|
||||||
|
}
|
||||||
|
mods := []inventory.DeclaredConfigMirrored{{Module: "n8n", Settings: declares["n8n"]}}
|
||||||
|
if _, has := mods[0].Settings["port"]; has || len(mods[0].Settings) != 1 {
|
||||||
|
t.Fatalf("only the added key is told, got %v", mods[0].Settings)
|
||||||
|
}
|
||||||
|
d := mods[0].Settings["media-gid"]
|
||||||
|
if d.Type != "number" || !d.Required {
|
||||||
|
t.Fatalf("the declaration is %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC1: a key whose declaration the head changes is sent; one it leaves as it was is not.
|
||||||
|
func TestAnUnchangedKeyIsNotToldAndAChangedOneIs(t *testing.T) {
|
||||||
|
fakeManifests(t, map[string]*catalogue.Manifest{
|
||||||
|
baseSHA: manifest("n8n", map[string]catalogue.SettingDeclaration{"port": {Type: "number"}, "x": {Type: "string"}}),
|
||||||
|
headSHA: manifest("n8n", map[string]catalogue.SettingDeclaration{"port": {Type: "number"}, "x": {Type: "string", Pattern: "a+"}}),
|
||||||
|
})
|
||||||
|
mods, touched, err := pullDeclarations(context.Background(), thePull())
|
||||||
|
if err != nil || !touched || len(mods) != 1 || len(mods[0].Settings) != 1 || mods[0].Settings["x"].Pattern != "a+" {
|
||||||
|
t.Fatalf("%+v %v %v", mods, touched, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC1: a head that changes no module.json, or removes it, is still told, with declares {}: mesh-config re-judges at every
|
||||||
|
// head, so a head that reverts the change drops the value held for it (review round 1, finding 1).
|
||||||
|
func TestAHeadThatDeclaresNothingIsToldAsEmpty(t *testing.T) {
|
||||||
|
fakeManifests(t, map[string]*catalogue.Manifest{baseSHA: manifest("n8n", nil), headSHA: manifest("n8n", nil)})
|
||||||
|
calls := fakeConfig(t, nil)
|
||||||
|
p := thePull()
|
||||||
|
p.Paths = []string{"modules/n8n/README.md"}
|
||||||
|
tellPullDeclares(context.Background(), io.Discard, []pullAt{p})
|
||||||
|
p = thePull()
|
||||||
|
p.Removed = p.Paths
|
||||||
|
tellPullDeclares(context.Background(), io.Discard, []pullAt{p})
|
||||||
|
if len(*calls) != 2 {
|
||||||
|
t.Fatalf("mesh-config was told %v", *calls)
|
||||||
|
}
|
||||||
|
for _, c := range *calls {
|
||||||
|
if d, _ := json.Marshal(c["declares"]); string(d) != "{}" || c["head"] != headSHA {
|
||||||
|
t.Fatalf("told %v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC1: a head whose paths are cut, or whose base is unknown, cannot be read: nothing is told, never an empty set.
|
||||||
|
func TestAHeadThatCannotBeReadIsNotToldAsEmpty(t *testing.T) {
|
||||||
|
calls := fakeConfig(t, nil)
|
||||||
|
p := thePull()
|
||||||
|
p.Truncated = true
|
||||||
|
tellPullDeclares(context.Background(), io.Discard, []pullAt{p})
|
||||||
|
if len(*calls) != 0 {
|
||||||
|
t.Fatalf("told %v", *calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC1: a module.json changed without a declaration changing is told as an empty set, so mesh-config re-judges what it holds.
|
||||||
|
func TestAChangedManifestWithNoNewDeclarationIsToldAsEmpty(t *testing.T) {
|
||||||
|
m := manifest("n8n", map[string]catalogue.SettingDeclaration{"port": {Type: "number"}})
|
||||||
|
fakeManifests(t, map[string]*catalogue.Manifest{baseSHA: m, headSHA: m})
|
||||||
|
calls := fakeConfig(t, nil)
|
||||||
|
tellPullDeclares(context.Background(), io.Discard, []pullAt{thePull()})
|
||||||
|
if len(*calls) != 1 {
|
||||||
|
t.Fatalf("calls %v", *calls)
|
||||||
|
}
|
||||||
|
if d, _ := json.Marshal((*calls)[0]["declares"]); string(d) != "{}" {
|
||||||
|
t.Fatalf("declares %s", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC1: mesh-config not answering is said and goes on; the check is not stopped.
|
||||||
|
func TestMeshConfigNotAnsweringIsSaidAndGoneOn(t *testing.T) {
|
||||||
|
fakeManifests(t, map[string]*catalogue.Manifest{baseSHA: manifest("n8n", nil),
|
||||||
|
headSHA: manifest("n8n", map[string]catalogue.SettingDeclaration{"k": {Type: "string"}})})
|
||||||
|
was := meshConfigAsk
|
||||||
|
t.Cleanup(func() { meshConfigAsk = was })
|
||||||
|
meshConfigAsk = func(context.Context, map[string]any) ([]byte, error) { return nil, io.ErrUnexpectedEOF }
|
||||||
|
var said strings.Builder
|
||||||
|
tellPullDeclares(context.Background(), &said, []pullAt{thePull()})
|
||||||
|
if !strings.Contains(said.String(), "mesh-config") {
|
||||||
|
t.Fatalf("said %q", said.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC1: a repository that builds a module of the mesh holds modules; another does not.
|
||||||
|
func TestARepositoryThatBuildsAModuleHoldsModules(t *testing.T) {
|
||||||
|
entries := []inventory.Entry{{Manifest: catalogue.Manifest{Module: "a"}, Source: inventory.Source{Repository: "novox/mesh-catalog", Path: "modules/a"}}}
|
||||||
|
if !holdsModules(entries, "novox", "mesh-catalog") || holdsModules(entries, "novox", "hq") {
|
||||||
|
t.Fatal("wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,7 +189,9 @@ func serve(ctx context.Context) (err error) {
|
|||||||
// Open plans move on events (novox/hq ADR 0406 §5, issue 974): a tier is asked on the `built` and the send of the
|
// 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
|
// 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.
|
// by a replaced controller resumes from the first look, which this asks for. No timer looks every so often.
|
||||||
startRolloutEvents(ctx, open, server.JetStream())
|
// **Its own grants on the bus before it writes** (novox/hq issue 1800): a bucket this controller newly writes is
|
||||||
|
// granted before the first write, not after its gate.
|
||||||
|
startWithOwnGrants(ctx, ownGrantsOf(open), func() { startRolloutEvents(ctx, open, server.JetStream()) })
|
||||||
// And the merge window (novox/hq ADR 0276, ADR 0407): a batch is cut into its walk when the window closes,
|
// And the merge window (novox/hq ADR 0276, ADR 0407): a batch is cut into its walk when the window closes,
|
||||||
// which no merge or outcome says. The window is two keys on the bus that expire and a watcher that hears it;
|
// which no merge or outcome says. The window is two keys on the bus that expire and a watcher that hears it;
|
||||||
// nothing polls (issue 973).
|
// nothing polls (issue 973).
|
||||||
@@ -281,11 +283,22 @@ func serve(ctx context.Context) (err error) {
|
|||||||
// The provisioner's plan of each handed module, as its machine was last sent it (design 57 P6), in the module's
|
// The provisioner's plan of each handed module, as its machine was last sent it (design 57 P6), in the module's
|
||||||
// assignment.
|
// assignment.
|
||||||
inv.SetProvisioningPlans(inv.SentProvisioningPlans)
|
inv.SetProvisioningPlans(inv.SentProvisioningPlans)
|
||||||
go mirroringRecords(ctx, inv, bus.Conn, bus)
|
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).
|
// 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)
|
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).
|
// 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)
|
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)
|
||||||
|
// And says what it keeps, and keeps what every module says it keeps (novox/hq ADR 0495, issue 1790): a changed word
|
||||||
|
// sends the machine holding the bus its new user list.
|
||||||
|
if err := announceOwnStates(ctx, server.JetStream()); err != nil {
|
||||||
|
fmt.Printf("states: %v\n", err)
|
||||||
|
}
|
||||||
|
if err := keepAnnouncements(ctx, server.JetStream(), open); err != nil {
|
||||||
|
fmt.Printf("states: the states the modules announce are not kept: %v\n", err)
|
||||||
|
}
|
||||||
// Every call carries the lease's epoch, and its record is written only under the lease (novox/hq
|
// Every call carries the lease's epoch, and its record is written only under the lease (novox/hq
|
||||||
// to-be 45 §6).
|
// to-be 45 §6).
|
||||||
link.Calls.UnderLease(func() (uint64, error) { return theLease.epoch(ctx) })
|
link.Calls.UnderLease(func() (uint64, error) { return theLease.epoch(ctx) })
|
||||||
@@ -1479,6 +1492,13 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
|||||||
if reheard > 0 {
|
if reheard > 0 {
|
||||||
fmt.Printf(" issued %d membership(s) again for what they hear of a module's stream\n", reheard)
|
fmt.Printf(" issued %d membership(s) again for what they hear of a module's stream\n", reheard)
|
||||||
}
|
}
|
||||||
|
// **And says again what it keeps** (novox/hq ADR 0495 clause 2, issue 1790): its word at start is refused until a push
|
||||||
|
// carries its grant; kept only when it changed, so saying it at every push changes nothing else.
|
||||||
|
if js := servingBus.Load(); js != nil {
|
||||||
|
if err := announceOwnStates(ctx, js); err != nil {
|
||||||
|
fmt.Printf(" states: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(refused) > 0 {
|
if len(refused) > 0 {
|
||||||
// Returned, never passed over (novox/hq issue 249): the declarations of the machines they are
|
// Returned, never passed over (novox/hq issue 249): the declarations of the machines they are
|
||||||
// for are not sent, and the rollout that asked is tried again rather than waiting for a push.
|
// for are not sent, and the rollout that asked is tried again rather than waiting for a push.
|
||||||
|
|||||||
+11
-191
@@ -477,89 +477,6 @@ func clearQueue(ctx context.Context, js *broker.JetStream, open *stores, seat st
|
|||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebuildCommand asks the module's current source again — or, given a build's id, that build's
|
|
||||||
// repository, path and ref — under a new id.
|
|
||||||
func rebuildCommand(ctx context.Context, args []string) error {
|
|
||||||
if len(args) != 1 {
|
|
||||||
return errors.New("rebuild <module | build id>")
|
|
||||||
}
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
inv := open.inventory
|
|
||||||
entries, err := inv.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var source buildSource
|
|
||||||
var path, ref, module string
|
|
||||||
if b, found, err := inv.BuildByID(ctx, args[0]); err != nil {
|
|
||||||
return err
|
|
||||||
} else if found {
|
|
||||||
source, module = sourceOfBuild(b, entries)
|
|
||||||
path, ref = b.Path, b.Ref
|
|
||||||
// **A build at a commit is rebuilt at what its module follows now** (novox/hq ADR 0219, issue
|
|
||||||
// 219): asked now, a rebuild of an old commit would be the newest ask of the module and roll
|
|
||||||
// that commit out over everything since. Building that commit again is `replay`, which says
|
|
||||||
// what it would do and refuses to register it while anything newer is asked or registered.
|
|
||||||
if e, known := entryNamed(entries, module); known && ref != "" && followedBranch(ref) == "" {
|
|
||||||
ref = followedBranch(e.Source.Ref)
|
|
||||||
follows := ref
|
|
||||||
if follows == "" {
|
|
||||||
follows = "the repository's default branch"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s was built at commit %s; a rebuild asks what %s follows now, %s — `replay %s` "+
|
|
||||||
"builds that commit\n", b.ID, short(b.Ref), module, follows, b.ID)
|
|
||||||
}
|
|
||||||
} else if e, known := entryNamed(entries, args[0]); known {
|
|
||||||
// As a plan asks it: the branch it follows, never a commit a build once named (issue 215).
|
|
||||||
source = buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
|
||||||
path, ref, module = e.Source.Path, followedBranch(e.Source.Ref), e.Manifest.Module
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("%s is neither a module the catalogue holds nor a build the mesh recorded", args[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A module a plan holds unbuilt, or failed, joins that plan** rather than running beside it: the
|
|
||||||
// plan would ask it again, or stay failed on an outcome a rebuild has replaced. A walk waiting for its word
|
|
||||||
// is not joined (novox/hq issue 754): the rebuild runs beside it, and says so.
|
|
||||||
var beside string
|
|
||||||
if module != "" {
|
|
||||||
joined, said, err := joinAPlan(ctx, open, module)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if joined {
|
|
||||||
fmt.Println(said)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
beside = said
|
|
||||||
}
|
|
||||||
id, err := askABuild(ctx, source, path, ref)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
recordAsked(ctx, inventory.BuildRequest{ID: id, Repository: source.Repository, Seat: source.Seat, Path: path,
|
|
||||||
Ref: ref, For: "rebuild"})
|
|
||||||
if beside != "" {
|
|
||||||
fmt.Printf("rebuild asked as %s, beside the walk: %s\n", id, beside)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Printf("rebuild asked as %s\n", id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// entryNamed is the catalogue's entry for a module.
|
|
||||||
func entryNamed(entries []inventory.Entry, name string) (inventory.Entry, bool) {
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.Manifest.Module == name {
|
|
||||||
return e, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return inventory.Entry{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceOfBuild is where a recorded build's repository is asked from: the catalogued module's own
|
// 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
|
// 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.
|
// (ADR 0111) — else the repository as it was cloned.
|
||||||
@@ -576,14 +493,17 @@ func sourceOfBuild(b inventory.Build, entries []inventory.Entry) (buildSource, s
|
|||||||
// replayCommand asks a recorded build's repository and path again at the commit it built.
|
// replayCommand asks a recorded build's repository and path again at the commit it built.
|
||||||
func replayCommand(ctx context.Context, args []string) error {
|
func replayCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("replay", flag.ContinueOnError)
|
set := flag.NewFlagSet("replay", flag.ContinueOnError)
|
||||||
register := set.Bool("register", false, "register what it builds, as any build is")
|
register := set.Bool("register", false, "removed (novox/hq ADR 0501 §5): refused by name")
|
||||||
older := set.Bool("older", false, "with --register: even though a newer build of the module is registered")
|
|
||||||
positionals, err := parseAround(set, args)
|
positionals, err := parseAround(set, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if len(positionals) != 1 {
|
||||||
return errors.New("replay <build id> [--register [--older]]")
|
return errors.New("replay <build id>")
|
||||||
}
|
}
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -603,86 +523,15 @@ func replayCommand(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
source, module := sourceOfBuild(b, entries)
|
source, module := sourceOfBuild(b, entries)
|
||||||
var history []inventory.Build
|
if b.Commit == "" {
|
||||||
if module != "" {
|
return fmt.Errorf("%s recorded no commit — it failed before it knew what it was building, so there is "+
|
||||||
if history, err = inv.Builds(ctx, module, 100); err != nil {
|
"nothing to replay", b.ID)
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// What is asked of the module and not yet answered, when the replay would be registered.
|
id, err := buildOneAsked(ctx, source, b.Path, b.Commit, 0, true, "")
|
||||||
var outstanding []string
|
|
||||||
if *register {
|
|
||||||
js, err := dialTheBus()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
q, err := link.ReadQueue(ctx, js, buildSeatHeld(ctx))
|
|
||||||
js.Close()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
plans, err := inv.OpenPlans(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
outstanding = outstandingFor(module, b.Repository, b.Path, q, plans)
|
|
||||||
}
|
|
||||||
if err := replayRefusal(b, module, history, *register, *older, outstanding); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
asker := ""
|
|
||||||
if *register {
|
|
||||||
asker = "replay"
|
|
||||||
}
|
|
||||||
id, err := buildOneAsked(ctx, source, b.Path, b.Commit, 0, !*register, asker)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println(replaySaid(b, module, id, *register))
|
fmt.Println(replaySaid(b, module, id, false))
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// replayRefusal is why a replay is not asked, or nothing (novox/hq ADR 0219, issue 207).
|
|
||||||
//
|
|
||||||
// A replay re-asks a recorded build at the commit it built, under a new id — and an id is when it
|
|
||||||
// was asked, which is what orders builds of one module (issue 219). So a registered replay of an
|
|
||||||
// older commit is newer than everything since, and would roll that older commit out as the module's
|
|
||||||
// current version: what issue 207 recorded happening by accident. It is therefore a dry run unless
|
|
||||||
// --register says otherwise, and --register is refused when a newer build of a different commit is
|
|
||||||
// registered, unless --older says that is the point.
|
|
||||||
//
|
|
||||||
// **And refused while anything newer is outstanding**, --older or not: an ask of the module in the
|
|
||||||
// queue, or an open plan holding it unbuilt. Asked now, the replay would be newer than those asks,
|
|
||||||
// and their builds — made from newer source — would be recorded and never registered (issue 219
|
|
||||||
// orders by ask), the plan waiting on them sending the older commit instead.
|
|
||||||
func replayRefusal(b inventory.Build, module string, history []inventory.Build, register, older bool,
|
|
||||||
outstanding []string) error {
|
|
||||||
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; `rebuild %s` asks its repository, path and ref again", b.ID, b.ID)
|
|
||||||
}
|
|
||||||
if older && !register {
|
|
||||||
return errors.New("--older only says what --register may do; a dry run registers nothing")
|
|
||||||
}
|
|
||||||
if register && len(outstanding) > 0 {
|
|
||||||
return fmt.Errorf("%s is asked and not yet answered — %s — and a registered replay asked now would "+
|
|
||||||
"replace what those build (novox/hq issue 219). Wait for them, or `replay %s` without --register to look",
|
|
||||||
orNone(module), strings.Join(outstanding, "; "), b.ID)
|
|
||||||
}
|
|
||||||
if !register || older {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, h := range history {
|
|
||||||
if h.ID == b.ID || !h.Worked() || h.Commit == b.Commit {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if h.AskedOrAt().After(b.AskedOrAt()) {
|
|
||||||
return fmt.Errorf("a newer build of %s is registered — %s, from %s — and registering a replay of %s "+
|
|
||||||
"would roll that older commit out as %s's current version (novox/hq issue 207). `replay %s` "+
|
|
||||||
"without --register looks at it; --register --older registers it anyway",
|
|
||||||
module, h.ID, short(h.Commit), short(b.Commit), module, b.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,32 +671,3 @@ func holdersAmong(entries []inventory.Entry, seat string) []string {
|
|||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// outstandingFor is everything asked of a module and not yet answered: its asks in the build queue,
|
|
||||||
// by repository and path, and every open plan holding it not yet built.
|
|
||||||
func outstandingFor(module, repository, path string, q link.Queue, plans []inventory.Plan) []string {
|
|
||||||
var out []string
|
|
||||||
for _, a := range q.Asks {
|
|
||||||
if repositoryMatches(a.Repository, repository) && a.Path == path {
|
|
||||||
out = append(out, fmt.Sprintf("%s %s in the queue", a.ID, a.State))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if module == "" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for _, p := range plans {
|
|
||||||
if !p.Open() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, tier := range p.Tiers {
|
|
||||||
for _, m := range tier {
|
|
||||||
if m == module {
|
|
||||||
if st := p.Modules[m]; st == nil || st.State != "built" {
|
|
||||||
out = append(out, fmt.Sprintf("%s holds it not yet built", p.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,44 +71,6 @@ func TestAFailedPlanIsRetriedAndGoesOnThroughItsLaterTiers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestARebuildJoinsThePlanHoldingTheModule(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-join", Repository: "novox/a", Branch: "main", Commit: "c0ffee",
|
|
||||||
Created: before, State: inventory.PlanFailed, 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.CancelledByHand}}}
|
|
||||||
if err := open.inventory.SavePlan(ctx, &failed); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := rebuildCommand(ctx, []string{"a"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
p, err := open.inventory.PlanByID(ctx, failed.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if p.State != inventory.PlanBuilding || p.Modules["a"].State != "asked" || p.Modules["a"].Build == "build-1" {
|
|
||||||
t.Fatalf("the rebuild did not join the failed plan: %s %+v", p.State, p.Modules["a"])
|
|
||||||
}
|
|
||||||
if len(*asked) != 1 {
|
|
||||||
t.Fatalf("asked %v", *asked)
|
|
||||||
}
|
|
||||||
// b is in a tier not yet reached: nothing holds it in its current tier, so it is asked alone.
|
|
||||||
if err := rebuildCommand(ctx, []string{"b"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if p, _ = open.inventory.PlanByID(ctx, failed.ID); p.Modules["b"] != nil {
|
|
||||||
t.Fatalf("a module the plan has not reached joined it: %+v", 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) {
|
func aBuildQueue(t *testing.T) (*broker.JetStream, func(id, repository string) link.BuildRequest) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
url := testbus.URL(t)
|
url := testbus.URL(t)
|
||||||
@@ -425,29 +387,6 @@ func TestAPlanIsAnsweredOnlyByTheBuildItAskedFor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestARebuildOfACommitAsksWhatTheModuleFollows(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
asked := asksRecorded(t)
|
|
||||||
twoTiers(t, open)
|
|
||||||
var refs []string
|
|
||||||
was := askABuild
|
|
||||||
askABuild = func(c context.Context, source buildSource, path, ref string) (string, error) {
|
|
||||||
refs = append(refs, ref)
|
|
||||||
return was(c, source, path, ref)
|
|
||||||
}
|
|
||||||
if err := open.inventory.RecordBuild(ctx, inventory.Build{ID: "build-at-commit", Repository: "novox/a",
|
|
||||||
Ref: "0123456789abcdef0123456789abcdef01234567", Module: "a", Commit: "0123456789abcdef0123456789abcdef01234567"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := rebuildCommand(ctx, []string{"build-at-commit"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(refs) != 1 || refs[0] != "main" || len(*asked) != 1 {
|
|
||||||
t.Fatalf("asked %v at %v", *asked, refs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCancelOfAnAskNobodySaidIsWithdrawnWhenItStarts(t *testing.T) {
|
func TestCancelOfAnAskNobodySaidIsWithdrawnWhenItStarts(t *testing.T) {
|
||||||
js, ask := aBuildQueue(t)
|
js, ask := aBuildQueue(t)
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
// asksRecorded makes every ask a plan makes return the next id in a row, and says which were asked.
|
// 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 {
|
func asksRecorded(t *testing.T) *[]string {
|
||||||
t.Helper()
|
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
|
var asked []string
|
||||||
was := askABuild
|
was := askABuild
|
||||||
askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) {
|
askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) {
|
||||||
@@ -100,89 +101,6 @@ func TestRetryRefusesWhatItCannotResume(t *testing.T) {
|
|||||||
|
|
||||||
// `rebuild` of a module a failed plan holds joins that plan; one held by nothing runs alone.
|
// `rebuild` of a module a failed plan holds joins that plan; one held by nothing runs alone.
|
||||||
|
|
||||||
// A failed plan an open plan of its repository supersedes is not joined: the open one holds the module.
|
|
||||||
func TestARebuildJoinsTheOpenPlanBeforeASupersededFailedOne(t *testing.T) {
|
|
||||||
at := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
|
||||||
failed := inventory.Plan{ID: "plan-old", Repository: "novox/a", Branch: "main", Created: at, State: inventory.PlanFailed,
|
|
||||||
Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{"a": {State: "failed"}}}
|
|
||||||
newer := inventory.Plan{ID: "plan-new", Repository: "novox/a", Branch: "main", Created: at.Add(time.Hour),
|
|
||||||
State: inventory.PlanBuilding, Tiers: [][]string{{"x"}, {"a"}}, Modules: map[string]*inventory.PlanModule{}}
|
|
||||||
if _, found := planHolding("a", []inventory.Plan{newer}, []inventory.Plan{newer, failed}); found {
|
|
||||||
t.Fatal("joined a failed plan a newer open one supersedes")
|
|
||||||
}
|
|
||||||
if p, found := planHolding("a", nil, []inventory.Plan{failed}); !found || p.ID != "plan-old" {
|
|
||||||
t.Fatalf("did not join the failed plan holding it: %v %s", found, p.ID)
|
|
||||||
}
|
|
||||||
built := failed
|
|
||||||
built.Modules = map[string]*inventory.PlanModule{"a": {State: "built"}}
|
|
||||||
if _, found := planHolding("a", nil, []inventory.Plan{built}); found {
|
|
||||||
t.Fatal("joined a plan that has the module built")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// replay is a dry run unless registered, and registering an older commit than one registered since
|
|
||||||
// is refused unless --older says it is meant (novox/hq issue 207).
|
|
||||||
func TestReplayRefusesToRollAnOlderCommitOutUnlessToldTo(t *testing.T) {
|
|
||||||
asked := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
|
||||||
old := inventory.Build{ID: "build-old", Module: "a", Commit: "0ldc0mm1t", Asked: asked}
|
|
||||||
newer := inventory.Build{ID: "build-new", Module: "a", Commit: "n3wc0mm1t", Asked: asked.Add(time.Hour)}
|
|
||||||
history := []inventory.Build{newer, old}
|
|
||||||
|
|
||||||
if err := replayRefusal(old, "a", history, false, false, nil); err != nil {
|
|
||||||
t.Errorf("a dry run was refused: %v", err)
|
|
||||||
}
|
|
||||||
err := replayRefusal(old, "a", history, true, false, nil)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "build-new") || !strings.Contains(err.Error(), "--older") {
|
|
||||||
t.Errorf("registering an older commit than the one registered was not refused: %v", err)
|
|
||||||
}
|
|
||||||
if err := replayRefusal(old, "a", history, true, true, nil); err != nil {
|
|
||||||
t.Errorf("--register --older was refused: %v", err)
|
|
||||||
}
|
|
||||||
if err := replayRefusal(newer, "a", history, true, false, nil); err != nil {
|
|
||||||
t.Errorf("registering the newest build again was refused: %v", err)
|
|
||||||
}
|
|
||||||
// A newer build of the same commit, or one that failed, is not a newer version to roll back from.
|
|
||||||
same := inventory.Build{ID: "build-same", Module: "a", Commit: old.Commit, Asked: asked.Add(2 * time.Hour)}
|
|
||||||
broken := inventory.Build{ID: "build-broken", Module: "a", Failed: "no", Asked: asked.Add(3 * time.Hour)}
|
|
||||||
if err := replayRefusal(old, "a", []inventory.Build{broken, same, old}, true, false, nil); err != nil {
|
|
||||||
t.Errorf("refused for a newer build of the same commit or a failed one: %v", err)
|
|
||||||
}
|
|
||||||
if err := replayRefusal(inventory.Build{ID: "build-x", Failed: "clone"}, "", nil, false, false, nil); err == nil {
|
|
||||||
t.Error("a build that recorded no commit was replayed")
|
|
||||||
}
|
|
||||||
if err := replayRefusal(old, "a", history, false, true, nil); err == nil {
|
|
||||||
t.Error("--older without --register was taken")
|
|
||||||
}
|
|
||||||
// **Nothing newer outstanding**, --older or not: an ask of the module in the queue, or an open plan
|
|
||||||
// holding it unbuilt, would be replaced by a replay asked now (issue 219).
|
|
||||||
q := link.Queue{Asks: []link.QueuedAsk{
|
|
||||||
{ID: "build-queued", Repository: "https://forge.example/novox/a.git", State: link.AskWaiting},
|
|
||||||
{ID: "build-elsewhere", Repository: "https://forge.example/novox/b.git", State: link.AskWaiting},
|
|
||||||
{ID: "build-other-path", Repository: "https://forge.example/novox/a.git", Path: "sub", State: link.AskWaiting},
|
|
||||||
}}
|
|
||||||
plans := []inventory.Plan{
|
|
||||||
{ID: "plan-holds", State: inventory.PlanBuilding, Tiers: [][]string{{"x"}, {"a"}}, Modules: map[string]*inventory.PlanModule{}},
|
|
||||||
{ID: "plan-built", State: inventory.PlanRolling, Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{"a": {State: "built"}}},
|
|
||||||
{ID: "plan-failed", State: inventory.PlanFailed, Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{}},
|
|
||||||
}
|
|
||||||
outstanding := outstandingFor("a", "novox/a", "", q, plans)
|
|
||||||
if len(outstanding) != 2 || !strings.Contains(outstanding[0], "build-queued") || !strings.Contains(outstanding[1], "plan-holds") {
|
|
||||||
t.Fatalf("outstanding: %v", outstanding)
|
|
||||||
}
|
|
||||||
if err := replayRefusal(old, "a", history, true, true, outstanding); err == nil || !strings.Contains(err.Error(), "build-queued") {
|
|
||||||
t.Errorf("registered over an outstanding ask: %v", err)
|
|
||||||
}
|
|
||||||
if err := replayRefusal(old, "a", history, false, false, outstanding); err != nil {
|
|
||||||
t.Errorf("a dry run was refused for what is outstanding: %v", err)
|
|
||||||
}
|
|
||||||
if said := replaySaid(old, "a", "build-1", false); !strings.Contains(said, "dry run") || !strings.Contains(said, "--register") {
|
|
||||||
t.Errorf("a dry replay does not say what it is: %q", said)
|
|
||||||
}
|
|
||||||
if said := replaySaid(old, "a", "build-1", true); !strings.Contains(said, "registered") || !strings.Contains(said, "rolled out") {
|
|
||||||
t.Errorf("a registered replay does not say what it does: %q", said)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plan waiting on builds of a seat whose every holder is paused says so and is not late; a seat
|
// 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.
|
// paused on some holders only is not a reason the plan is waiting.
|
||||||
func TestAPlanWaitingOnAPausedSeatSaysSoAndIsNotLate(t *testing.T) {
|
func TestAPlanWaitingOnAPausedSeatSaysSoAndIsNotLate(t *testing.T) {
|
||||||
@@ -240,9 +158,7 @@ func TestTheQueueVerbsRunTheirCommands(t *testing.T) {
|
|||||||
{"cancel", map[string]any{"id": "build-1"}, []string{"cancel", "build-1"}},
|
{"cancel", map[string]any{"id": "build-1"}, []string{"cancel", "build-1"}},
|
||||||
{"clear", nil, []string{"clear"}},
|
{"clear", nil, []string{"clear"}},
|
||||||
{"clear", map[string]any{"dead": "true"}, []string{"clear", "--dead"}},
|
{"clear", map[string]any{"dead": "true"}, []string{"clear", "--dead"}},
|
||||||
{"rebuild", map[string]any{"what": "gitea"}, []string{"rebuild", "gitea"}},
|
|
||||||
{"replay", map[string]any{"id": "build-1"}, []string{"replay", "build-1"}},
|
{"replay", map[string]any{"id": "build-1"}, []string{"replay", "build-1"}},
|
||||||
{"replay", map[string]any{"id": "build-1", "register": "true", "older": "true"}, []string{"replay", "build-1", "--register", "--older"}},
|
|
||||||
{"kill", map[string]any{"id": "build-1"}, []string{"kill", "build-1"}},
|
{"kill", map[string]any{"id": "build-1"}, []string{"kill", "build-1"}},
|
||||||
{"pause", nil, []string{"pause"}},
|
{"pause", nil, []string{"pause"}},
|
||||||
{"resume", map[string]any{"node": "ace"}, []string{"resume", "ace"}},
|
{"resume", map[string]any{"node": "ace"}, []string{"resume", "ace"}},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user