Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4463991e06 | ||
|
|
4357b3688f | ||
|
|
03927a6f6c | ||
|
|
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 |
@@ -117,7 +117,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 +135,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 +147,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,6 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,14 +75,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 +154,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 +162,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 +173,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,3 +160,64 @@ 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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()) },
|
||||||
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})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,16 @@ func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Wri
|
|||||||
scope.Modules, scope.Dependents, scope.New, scope.Manifests = nil, nil, nil, nil
|
scope.Modules, scope.Dependents, scope.New, scope.Manifests = nil, nil, nil, nil
|
||||||
composedGate(&direct)
|
composedGate(&direct)
|
||||||
}
|
}
|
||||||
|
// **Mesh-config is told what the head declares at every announced head of a pull request in a repository that holds
|
||||||
|
// modules** (novox/hq ADR 0502 clauses 2 and 6, issue 1826), also when the head touches no module: a head that reverts
|
||||||
|
// the change must drop the value held for it. Asked of nothing else here.
|
||||||
|
this := pullAtOf(p)
|
||||||
|
if this.Truncated {
|
||||||
|
this.Paths, this.Truncated = truncatedPaths(entries, p.Owner, p.Repo, scope.Modules), false
|
||||||
|
}
|
||||||
|
if scope.gated() || holdsModules(entries, p.Owner, p.Repo) {
|
||||||
|
tellPullDeclares(ctx, w, []pullAt{this})
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case !scope.gated() && !scope.Mesh:
|
case !scope.gated() && !scope.Mesh:
|
||||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n",
|
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n",
|
||||||
@@ -209,6 +219,9 @@ func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Wri
|
|||||||
}
|
}
|
||||||
request.Check.Plan = &plan
|
request.Check.Plan = &plan
|
||||||
request.Check.Composed = composed
|
request.Check.Composed = composed
|
||||||
|
if !composed {
|
||||||
|
carryHeld(ctx, w, &request, []pullAt{this})
|
||||||
|
}
|
||||||
// **The group's other open pull requests are read beside it** (novox/hq ADR 0382 §1).
|
// **The group's other open pull requests are read beside it** (novox/hq ADR 0382 §1).
|
||||||
siblingsFrom(&request, p, entries, lines, groupsErr)
|
siblingsFrom(&request, p, entries, lines, groupsErr)
|
||||||
seat := buildSeatHeld(ctx)
|
seat := buildSeatHeld(ctx)
|
||||||
@@ -594,11 +607,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 +642,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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1177,6 +1177,12 @@ func askGroupCheck(ctx context.Context, open *stores, group string, members []or
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
request.Check.Group = group
|
request.Check.Group = group
|
||||||
|
// **The values held for the keys the members introduce are told and carried** (novox/hq ADR 0502, issue 1826).
|
||||||
|
var pulls []pullAt
|
||||||
|
for i, s := range changeSourcesOf(members, entries, reach) {
|
||||||
|
pulls = append(pulls, pullAt{changeSource: s, Number: members[i].pull().Number})
|
||||||
|
}
|
||||||
|
withHeldConfig(ctx, os.Stderr, &request, pulls, true)
|
||||||
// A group's check is queued as ready when any member's pull request is marked ready.
|
// 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 })
|
request.Check.Ready = slices.ContainsFunc(members, func(m orderMember) bool { return m.Ready })
|
||||||
world, err := restOfTheMesh(ctx, open, shelfOf(entries), "")
|
world, err := restOfTheMesh(ctx, open, shelfOf(entries), "")
|
||||||
@@ -1204,7 +1210,7 @@ func askGroupCheck(ctx context.Context, open *stores, group string, members []or
|
|||||||
return "", fmt.Errorf("%s cannot be cloned for the group's check: %w", m.ID, err)
|
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,
|
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})
|
Number: mp.Number, Repository: url, Ref: mp.Commit, Paths: mp.Paths, Base: mp.Base, Trunk: mp.BaseSHA})
|
||||||
}
|
}
|
||||||
seat := buildSeatHeld(ctx)
|
seat := buildSeatHeld(ctx)
|
||||||
ask, err := askOverOn(seat)
|
ask, err := askOverOn(seat)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,211 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldFor asks mesh-config for the approved held values of these pull requests at their heads (ADR 0502 clause 4). Only
|
||||||
|
// those of the pull requests asked about are carried. A failure is said to w and answers none.
|
||||||
|
func heldFor(ctx context.Context, w io.Writer, pulls []pullAt) []link.HeldConfig {
|
||||||
|
if len(pulls) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type pullHead struct {
|
||||||
|
Pull string `json:"pull"`
|
||||||
|
Head string `json:"head"`
|
||||||
|
}
|
||||||
|
asked := make([]pullHead, 0, len(pulls))
|
||||||
|
for _, p := range pulls {
|
||||||
|
asked = append(asked, pullHead{p.key(), p.New})
|
||||||
|
}
|
||||||
|
raw, err := meshConfigAsk(ctx, map[string]any{"action": "held", "pulls": asked})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "mesh-config cannot say what it holds for these pull requests (%v); the check goes on without held values\n", err)
|
||||||
|
return nil // empty-on-error: said above; a check without held values fails for want of the value and says so, it never passes on a guess
|
||||||
|
}
|
||||||
|
var answer struct {
|
||||||
|
Held []link.HeldConfig `json:"held"`
|
||||||
|
Skipped []struct {
|
||||||
|
Pull string `json:"pull"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
} `json:"skipped"`
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||||
|
fmt.Fprintf(w, "mesh-config's held values cannot be read (%v); the check goes on without them\n", err)
|
||||||
|
return nil // empty-on-error: said above; a check without held values fails for want of the value and says so, it never passes on a guess
|
||||||
|
}
|
||||||
|
for _, s := range answer.Skipped {
|
||||||
|
fmt.Fprintf(w, "%s: mesh-config holds a value it does not carry: %s\n", s.Pull, s.Why)
|
||||||
|
}
|
||||||
|
var out []link.HeldConfig
|
||||||
|
for _, h := range answer.Held {
|
||||||
|
for _, p := range pulls {
|
||||||
|
if h.Pull == p.key() {
|
||||||
|
out = append(out, h)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// withHeldConfig tells mesh-config what these pull requests declare and puts the approved held values of them on the
|
||||||
|
// check request (ADR 0502 clause 4). With carry false, a member of a group whose composed check composes, only the
|
||||||
|
// declarations are told.
|
||||||
|
func withHeldConfig(ctx context.Context, w io.Writer, request *link.BuildRequest, pulls []pullAt, carry bool) {
|
||||||
|
tellPullDeclares(ctx, w, pulls)
|
||||||
|
if carry {
|
||||||
|
carryHeld(ctx, w, request, pulls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// carryHeld puts the approved held values of these pull requests on the check request.
|
||||||
|
func carryHeld(ctx context.Context, w io.Writer, request *link.BuildRequest, pulls []pullAt) {
|
||||||
|
if request.Check != nil {
|
||||||
|
request.Check.HeldConfig = heldFor(ctx, w, pulls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,209 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()})
|
||||||
|
got := heldFor(context.Background(), &said, []pullAt{thePull()})
|
||||||
|
if len(got) != 0 || !strings.Contains(said.String(), "mesh-config") {
|
||||||
|
t.Fatalf("held %v, said %q", got, said.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC2: the held values mesh-config answers for the check's pull requests are what the check request carries.
|
||||||
|
func TestTheHeldValuesOfTheChecksPullRequestsAreAsked(t *testing.T) {
|
||||||
|
calls := fakeConfig(t, map[string]string{"held": `{"held":[{"pull":"novox/mesh-catalog#434","head":"` + headSHA +
|
||||||
|
`","node":"ace","module":"n8n","key":"media-gid","value":2000,"approved":"the operator"}]}`})
|
||||||
|
other := pullAt{Number: 7, changeSource: changeSource{Owner: "novox", Repo: "mesh-controller", New: baseSHA}}
|
||||||
|
got := heldFor(context.Background(), io.Discard, []pullAt{thePull(), other})
|
||||||
|
if len(got) != 1 || got[0].Node != "ace" || got[0].Key != "media-gid" || string(got[0].Value) != "2000" {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
c := (*calls)[0]
|
||||||
|
pulls, _ := json.Marshal(c["pulls"])
|
||||||
|
if c["action"] != "held" || string(pulls) != `[{"pull":"novox/mesh-catalog#434","head":"`+headSHA+
|
||||||
|
`"},{"pull":"novox/mesh-controller#7","head":"`+baseSHA+`"}]` {
|
||||||
|
t.Fatalf("asked %v %s", c, pulls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC2: a value held for a pull request the check is not of is never carried.
|
||||||
|
func TestAHeldValueOfAnotherPullRequestIsNotCarried(t *testing.T) {
|
||||||
|
fakeConfig(t, map[string]string{"held": `{"held":[{"pull":"novox/mesh-catalog#999","head":"x","node":"ace","module":"n8n","key":"k","value":1}]}`})
|
||||||
|
if got := heldFor(context.Background(), io.Discard, []pullAt{thePull()}); len(got) != 0 {
|
||||||
|
t.Fatalf("carried %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1826/AC2: the check request carries what mesh-config answered; a member of a composed group asks none.
|
||||||
|
func TestTheCheckRequestCarriesTheHeldValues(t *testing.T) {
|
||||||
|
fakeManifests(t, map[string]*catalogue.Manifest{baseSHA: manifest("n8n", nil), headSHA: manifest("n8n", nil)})
|
||||||
|
fakeConfig(t, map[string]string{"held": `{"held":[{"pull":"novox/mesh-catalog#434","node":"ace","module":"n8n","key":"media-gid","value":2000}]}`})
|
||||||
|
request := link.BuildRequest{Check: &link.CheckRequest{}}
|
||||||
|
withHeldConfig(context.Background(), io.Discard, &request, []pullAt{thePull()}, true)
|
||||||
|
if len(request.Check.HeldConfig) != 1 || request.Check.HeldConfig[0].Key != "media-gid" {
|
||||||
|
t.Fatalf("the request carries %+v", request.Check.HeldConfig)
|
||||||
|
}
|
||||||
|
request = link.BuildRequest{Check: &link.CheckRequest{}}
|
||||||
|
withHeldConfig(context.Background(), io.Discard, &request, []pullAt{thePull()}, false)
|
||||||
|
if len(request.Check.HeldConfig) != 0 {
|
||||||
|
t.Fatalf("a composed member carries %+v", request.Check.HeldConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,15 +181,24 @@ func planModuleNames(p inventory.Plan) map[string]string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordedWaiting is, on one machine, every move of a recorded module that waits for a person, each said with why
|
// heldMove is one move of a recorded module that waits for a person on one machine (ADR 0377 §6).
|
||||||
// (ADR 0377 §6): what `status` names the machine behind for, with `push <node>` as the remedy. A move under a
|
type heldMove struct {
|
||||||
// module's own `record` that recreates nothing and moves no data is not among them: it goes with its walk.
|
Module string
|
||||||
func recordedWaiting(ctx context.Context, inv *inventory.Inventory, f moveFacts, node string) ([]string, error) {
|
From, To string
|
||||||
|
// Why is the walk's own word for it: "waits: " and the recreate words, the data change, or the hold.
|
||||||
|
Why string
|
||||||
|
// Chosen is whether a person's own choice holds it — a module they set to record, or the bus's step — which the
|
||||||
|
// operator is not asked about again: they chose that hold, and the bus has its own condition (S17).
|
||||||
|
Chosen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldMovesOn is, on one machine, every move of a recorded module that waits for a person, in module order.
|
||||||
|
func heldMovesOn(ctx context.Context, inv *inventory.Inventory, f moveFacts, node string) ([]heldMove, error) {
|
||||||
sent, known, err := inv.SentBuilds(ctx, node)
|
sent, known, err := inv.SentBuilds(ctx, node)
|
||||||
if err != nil || !known {
|
if err != nil || !known {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var out []string
|
var out []heldMove
|
||||||
for _, m := range sortedKeysOf(sent) {
|
for _, m := range sortedKeysOf(sent) {
|
||||||
was := sent[m]
|
was := sent[m]
|
||||||
now, held := f.current[m]
|
now, held := f.current[m]
|
||||||
@@ -197,12 +206,25 @@ func recordedWaiting(ctx context.Context, inv *inventory.Inventory, f moveFacts,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if v := f.recordedMove(m, was); !v.rolls {
|
if v := f.recordedMove(m, was); !v.rolls {
|
||||||
out = append(out, fmt.Sprintf("%s %s → %s %s", m, short(was), short(now.Commit), v.said))
|
out = append(out, heldMove{Module: m, From: was, To: now.Commit, Why: v.said,
|
||||||
|
Chosen: now.From == catalogue.FromPerson || now.From == catalogue.FromBus})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordedWaiting is, on one machine, every move of a recorded module that waits for a person, each said with why
|
||||||
|
// (ADR 0377 §6): what `status` names the machine behind for, with `push <node>` as the remedy. A move under a
|
||||||
|
// module's own `record` that recreates nothing and moves no data is not among them: it goes with its walk.
|
||||||
|
func recordedWaiting(ctx context.Context, inv *inventory.Inventory, f moveFacts, node string) ([]string, error) {
|
||||||
|
moves, err := heldMovesOn(ctx, inv, f, node)
|
||||||
|
var out []string
|
||||||
|
for _, h := range moves {
|
||||||
|
out = append(out, fmt.Sprintf("%s %s → %s %s", h.Module, short(h.From), short(h.To), h.Why))
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// stringsKeys is a map of lists as a map of its keys, for sortedKeysOf.
|
// stringsKeys is a map of lists as a map of its keys, for sortedKeysOf.
|
||||||
func stringsKeys(m map[string][]string) map[string]string {
|
func stringsKeys(m map[string][]string) map[string]string {
|
||||||
out := map[string]string{}
|
out := map[string]string{}
|
||||||
|
|||||||
@@ -1371,6 +1371,10 @@ func backlogObservation() []conditions.Observation {
|
|||||||
"them, `upgrade release-backlog --why …` releases them", n, strings.Join(machines, ", "), backlogNow.held)}
|
"them, `upgrade release-backlog --why …` releases them", n, strings.Join(machines, ", "), backlogNow.held)}
|
||||||
w := releaseHeldWords(modules, machines)
|
w := releaseHeldWords(modules, machines)
|
||||||
o.Headline, o.Explanation, o.Resolved, o.Needs = w.Headline, w.Explanation, w.Resolved, w.Needs
|
o.Headline, o.Explanation, o.Resolved, o.Needs = w.Headline, w.Explanation, w.Resolved, w.Needs
|
||||||
|
// Asked of the operator, who releases them with an Approve (novox/hq issue 1732): the controller's own
|
||||||
|
// `upgrade release-backlog`, which opens the release plan that walks them through the machines, each judged.
|
||||||
|
o.Actions = []conditions.Action{{Label: "Release", Verb: "mesh-controller.upgrade", Level: conditions.LevelApprove,
|
||||||
|
Arguments: map[string]string{"release-backlog": "true", "why": "", "cause": conditions.CauseOperatorAnswer}}}
|
||||||
return []conditions.Observation{o}
|
return []conditions.Observation{o}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2064,6 +2064,12 @@ func planLineSaid(p inventory.Plan, now time.Time, pause pauseView, bound time.D
|
|||||||
// refused ("a build waits there for its gate: <machine> (...)") is kept there. "building" with LATE
|
// refused ("a build waits there for its gate: <machine> (...)") is kept there. "building" with LATE
|
||||||
// hid that reason from `plans`, `status` and the stalled condition (novox/hq issue 920).
|
// hid that reason from `plans`, `status` and the stalled condition (novox/hq issue 920).
|
||||||
what = p.Note
|
what = p.Note
|
||||||
|
// And when the builds are held after a failed release, they wait for a person, not for a build, and the
|
||||||
|
// operator is asked to release them (novox/hq issue 1732): said so, and not late.
|
||||||
|
if strings.Contains(p.Note, errUngated.Error()) && backlogNow.held != "" {
|
||||||
|
what += " — held after a failed delivery until a person releases it (the operator is asked)"
|
||||||
|
late = ""
|
||||||
|
}
|
||||||
return fmt.Sprintf("%s · %s, held: %s, for %s%s", name, where, what, since, late)
|
return fmt.Sprintf("%s · %s, held: %s, for %s%s", name, where, what, since, late)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s · %s, %s for %s%s", name, where, what, since, late)
|
return fmt.Sprintf("%s · %s, %s for %s%s", name, where, what, since, late)
|
||||||
@@ -2271,7 +2277,7 @@ func plansCommand(ctx context.Context, args []string) error {
|
|||||||
bounds.of(p.Repository), func(module string) []string {
|
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, []inventory.Plan{p})))
|
}, waitsOf(ctx, inv, []inventory.Plan{p}))+heldSaid(p, heldNow(ctx, inv)))
|
||||||
if r := p.Release; r != nil {
|
if r := p.Release; r != nil {
|
||||||
// A release plan's walk (ADR 0236): machines done, the one judged, those to come.
|
// A release plan's walk (ADR 0236): machines done, the one judged, those to come.
|
||||||
fmt.Printf(" machines in order: %s; done: %s; skipped: %s\n", strings.Join(r.Order, ", "),
|
fmt.Printf(" machines in order: %s; done: %s; skipped: %s\n", strings.Join(r.Order, ", "),
|
||||||
@@ -2466,11 +2472,12 @@ func plansCommand(ctx context.Context, args []string) error {
|
|||||||
return on
|
return on
|
||||||
}
|
}
|
||||||
waits := waitsOf(ctx, inv, plans)
|
waits := waitsOf(ctx, inv, plans)
|
||||||
|
held := heldNow(ctx, inv)
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
if p.Batch() {
|
if p.Batch() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("%-28s %s\n", p.ID, planLineSaid(p, now, pause, bounds.of(p.Repository), running, waits))
|
fmt.Printf("%-28s %s%s\n", p.ID, planLineSaid(p, now, pause, bounds.of(p.Repository), running, waits), heldSaid(p, held))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ func answersRightly(q dnsmessage.Question) *dnsmessage.Message {
|
|||||||
func quickResolvers(t *testing.T, port string) {
|
func quickResolvers(t *testing.T, port string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
before, within, pause := resolverPort, resolverWithin, resolverPause
|
before, within, pause := resolverPort, resolverWithin, resolverPause
|
||||||
resolverPort, resolverWithin, resolverPause = port, 150*time.Millisecond, 10*time.Millisecond
|
resolverPort, resolverWithin, resolverPause = port, 60*time.Millisecond, 5*time.Millisecond
|
||||||
t.Cleanup(func() { resolverPort, resolverWithin, resolverPause = before, within, pause })
|
t.Cleanup(func() { resolverPort, resolverWithin, resolverPause = before, within, pause })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,22 @@ var signalsTable = []signalRow{
|
|||||||
newest: func(f *signalFacts) time.Time {
|
newest: func(f *signalFacts) time.Time {
|
||||||
return newestOf(f.failed, func(p failedPlanFacts) time.Time { return p.since })
|
return newestOf(f.failed, func(p failedPlanFacts) time.Time { return p.since })
|
||||||
}},
|
}},
|
||||||
|
{Row: "S24", Signal: "a move of a recorded module that waits for a person's push is asked of the operator",
|
||||||
|
Emitter: "controller's plan", Trigger: "each machine with a move of a recorded module held for a person (novox/hq ADR 0377, issue 1732)",
|
||||||
|
Bound: "none: raised on the first look that finds the machine holding such a move, naming the modules and the " +
|
||||||
|
"machine, with Release (the machine's push) as the answer; ended when the machine no longer waits, whoever " +
|
||||||
|
"pushed it. A hold a person chose (a module set to record with a why, the bus) is not asked.",
|
||||||
|
Kind: kindMovesHeld, Severity: conditions.Warning, Phase: 3,
|
||||||
|
needs: func(f *signalFacts) error { return f.heldErr }, watch: watchMovesHeld,
|
||||||
|
newest: func(f *signalFacts) time.Time { return time.Time{} }},
|
||||||
|
{Row: "S25", Signal: "a module left out of a send because the artifact store lacks an image it names",
|
||||||
|
Emitter: "controller's send", Trigger: "each send whose declaration names an image of the store that the store does not hold (novox/hq issue 1742)",
|
||||||
|
Bound: "none: raised on the first look at a registered build that names such an image, naming the module, each " +
|
||||||
|
"machine that runs it and the image; every send leaves that module out; the store is asked again at each " +
|
||||||
|
"look and the condition ends when it holds the image",
|
||||||
|
Kind: kindImageMissing, Severity: conditions.Warning, Phase: 3,
|
||||||
|
needs: func(f *signalFacts) error { return f.imagesErr }, watch: watchMissingImages,
|
||||||
|
newest: func(f *signalFacts) time.Time { return time.Time{} }},
|
||||||
{Row: "S20", Signal: "a send refused for the assignment generation it was composed from",
|
{Row: "S20", Signal: "a send refused for the assignment generation it was composed from",
|
||||||
Emitter: "node-engine", Trigger: "each refusal (novox/hq issue 234)",
|
Emitter: "node-engine", Trigger: "each refusal (novox/hq issue 234)",
|
||||||
Bound: "none: raised at the first refusal, naming its sender, the generation it came from and the one the " +
|
Bound: "none: raised at the first refusal, naming its sender, the generation it came from and the one the " +
|
||||||
|
|||||||
@@ -171,6 +171,19 @@ var suppressions = map[string]suppression{
|
|||||||
since: f.now.Add(-time.Second)}}
|
since: f.now.Add(-time.Second)}}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// A machine holding a move of a recorded module for a person is asked at its first look (novox/hq issue 1732).
|
||||||
|
// Inside: nothing waits there.
|
||||||
|
"S24": {
|
||||||
|
inside: func(f *signalFacts) { f.held = nil },
|
||||||
|
past: func(f *signalFacts) { f.held = []heldFacts{aHeldMailu()} },
|
||||||
|
},
|
||||||
|
// A registered build naming an image the artifact store lacks is raised at the first look (novox/hq issue 1742).
|
||||||
|
"S25": {
|
||||||
|
inside: func(f *signalFacts) { f.images = nil },
|
||||||
|
past: func(f *signalFacts) {
|
||||||
|
f.images = []imageFacts{{missingImage{Node: "anchor", Module: "automx", Image: automxImage}}}
|
||||||
|
},
|
||||||
|
},
|
||||||
// A batch queued behind an open walk past that walk's bound, named.
|
// A batch queued behind an open walk past that walk's bound, named.
|
||||||
"S19": {
|
"S19": {
|
||||||
inside: func(f *signalFacts) {
|
inside: func(f *signalFacts) {
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ type signalFacts struct {
|
|||||||
// failed are the delivery plans that failed and are asked of the operator (S23, novox/hq ADR 0449).
|
// failed are the delivery plans that failed and are asked of the operator (S23, novox/hq ADR 0449).
|
||||||
failed []failedPlanFacts
|
failed []failedPlanFacts
|
||||||
failedErr error
|
failedErr error
|
||||||
|
// held are the machines with moves of recorded modules waiting for a person's push (S24, novox/hq issue 1732).
|
||||||
|
held []heldFacts
|
||||||
|
heldErr error
|
||||||
|
// images are the modules left out of a send because the artifact store lacks an image they name (S25, issue 1742).
|
||||||
|
images []imageFacts
|
||||||
|
imagesErr error
|
||||||
// refusedSends are the sends a machine refused within the hour for the generation they were composed
|
// refusedSends are the sends a machine refused within the hour for the generation they were composed
|
||||||
// from, each naming its sender (S20, novox/hq issue 234).
|
// from, each naming its sender (S20, novox/hq issue 234).
|
||||||
refusedSends []inventory.Send
|
refusedSends []inventory.Send
|
||||||
@@ -357,6 +363,8 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts {
|
|||||||
f.batches, f.plansErr = gatherBatches(ctx, inv, now)
|
f.batches, f.plansErr = gatherBatches(ctx, inv, now)
|
||||||
}
|
}
|
||||||
f.failed, f.failedErr = gatherFailedPlans(ctx, inv, now)
|
f.failed, f.failedErr = gatherFailedPlans(ctx, inv, now)
|
||||||
|
f.held, f.heldErr = gatherHeldMoves(ctx, inv)
|
||||||
|
f.images, f.imagesErr = gatherMissingImagesHere(ctx, inv)
|
||||||
f.loop, f.loopErr = w.gatherLoop()
|
f.loop, f.loopErr = w.gatherLoop()
|
||||||
f.asks, f.asksErr = w.gatherAsks(ctx, inv)
|
f.asks, f.asksErr = w.gatherAsks(ctx, inv)
|
||||||
if open, err := w.keeper.Open(ctx); err != nil {
|
if open, err := w.keeper.Open(ctx); err != nil {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021153908-725590e06e75
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021204452-580da61a9107
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4=
|
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE=
|
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021153908-725590e06e75 h1:AcYfyJpYaUufhI93VG76+b001msXY4XX7kuRjnf33HY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021173436-07a65927c362 h1:Ud/YHmhlm0qdB45LyDvxq7l4x6Bg2Ye0siHK5JFE+kk=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021153908-725590e06e75/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021173436-07a65927c362/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021204452-580da61a9107 h1:29NSUkDw7cIEwNcE09JZ9ASSD+cwBDoWRN3vWHKvsoQ=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021204452-580da61a9107/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, Ass
|
|||||||
// ControllerMirrors are the local names of the controller's buckets a module may read: the record mirrors and the
|
// ControllerMirrors are the local names of the controller's buckets a module may read: the record mirrors and the
|
||||||
// chain's buckets.
|
// chain's buckets.
|
||||||
func ControllerMirrors() []string {
|
func ControllerMirrors() []string {
|
||||||
return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, ChecksRunning}
|
return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, ChecksRunning, PlansBuilding}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule
|
// MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule
|
||||||
|
|||||||
+14
-2
@@ -361,9 +361,11 @@ var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"}
|
|||||||
|
|
||||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||||
// controller's own `plans`. Named one by one; a node seat's on any machine, a mesh seat's flat.
|
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
|
||||||
|
// sent by its own `upgrade release-backlog` (novox/hq issue 1732). Named one by one; a node seat's on any machine, a
|
||||||
|
// mesh seat's flat.
|
||||||
var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"},
|
var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"},
|
||||||
{Seat: ControllerSeat, Verb: "plans"}}
|
{Seat: ControllerSeat, Verb: "plans"}, {Seat: ControllerSeat, Verb: "push"}, {Seat: ControllerSeat, Verb: "upgrade"}}
|
||||||
|
|
||||||
// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq
|
// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq
|
||||||
// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named,
|
// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named,
|
||||||
@@ -387,6 +389,16 @@ type DegradableGrant struct {
|
|||||||
// works. Every other grant stays required — missing one of those is a controller that comes up and is
|
// works. Every other grant stays required — missing one of those is a controller that comes up and is
|
||||||
// refused on the first thing it tries, with no fallback that covers it.
|
// refused on the first thing it tries, with no fallback that covers it.
|
||||||
var DegradableGrants = []DegradableGrant{
|
var DegradableGrants = []DegradableGrant{
|
||||||
|
{
|
||||||
|
Subject: "mesh.seat." + ControllerSeat + ".tool.push",
|
||||||
|
Reason: "refused: the ask of a held move (Release) is answered, and the refusal told, while the operator's own " +
|
||||||
|
"push at the terminal still sends the machine (issue 1732; ADR 0259)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Subject: "mesh.seat." + ControllerSeat + ".tool.upgrade",
|
||||||
|
Reason: "refused: the ask of the builds held after a failed release is answered, and the refusal told, while the " +
|
||||||
|
"operator's own `upgrade release-backlog` at the terminal still releases them (issue 1732; ADR 0259)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Subject: "mesh.seat." + VerbsTheControllerAsksTheOperatorChannel[0].Seat + ".tool." + VerbsTheControllerAsksTheOperatorChannel[0].Verb,
|
Subject: "mesh.seat." + VerbsTheControllerAsksTheOperatorChannel[0].Seat + ".tool." + VerbsTheControllerAsksTheOperatorChannel[0].Verb,
|
||||||
Reason: "refused: the controller raises the secret-given condition as before (ADR 0380 §9)",
|
Reason: "refused: the controller raises the secret-given condition as before (ADR 0380 §9)",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 1687 (ADR 0483 clause 2): a module whose manifest reads mesh-controller.plans-building may name it in `reads`, and
|
||||||
|
// reads the bucket and writes none; the controller writes it, so it is no mirror written by another.
|
||||||
|
func TestAModuleReadingPlansBuildingReadsAndWritesNone(t *testing.T) {
|
||||||
|
if !slices.Contains(ControllerMirrors(), "plans-building") || BucketName(ControllerSeat, PlansBuilding) != PlansBuildingBucket {
|
||||||
|
t.Fatalf("a module cannot name %s in `reads`: %v", PlansBuildingBucket, ControllerMirrors())
|
||||||
|
}
|
||||||
|
if _, other := MirrorsWrittenByAnother[PlansBuilding]; other {
|
||||||
|
t.Error("the controller writes the plans building itself")
|
||||||
|
}
|
||||||
|
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", PasswordHash: "x",
|
||||||
|
Reads: []string{"mesh-controller.plans-building"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stream := "KV_" + PlansBuildingBucket
|
||||||
|
has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream)
|
||||||
|
has(t, perms.Publish, "$JS.API.DIRECT.GET."+stream+".>")
|
||||||
|
for _, p := range perms.Publish {
|
||||||
|
if SubjectsOverlap(p, "$KV."+PlansBuildingBucket+".plan-1") {
|
||||||
|
t.Errorf("the reading module may write %s through %q", PlansBuildingBucket, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -29,7 +29,7 @@ accounts {
|
|||||||
]
|
]
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_plans-building.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_plans-building.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
||||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ var WritersTable = []WriterRow{
|
|||||||
KeptIn: "key-value " + CheckRunningBucket + ", aged out when not renewed", Others: "the build seat's holders read it to drop a duplicate request",
|
KeptIn: "key-value " + CheckRunningBucket + ", aged out when not renewed", Others: "the build seat's holders read it to drop a duplicate request",
|
||||||
Subjects: kvOf(CheckRunningBucket), Writes: holdsTheBuildSeat,
|
Subjects: kvOf(CheckRunningBucket), Writes: holdsTheBuildSeat,
|
||||||
Shared: "every machine holding the build seat puts the key of the check it runs and deletes it when the check ends"},
|
Shared: "every machine holding the build seat puts the key of the check it runs and deletes it when the check ends"},
|
||||||
{State: "a delivery plan in its build phase", Writer: "controller (lease holder), at the first build's ask and at each refresh",
|
{State: "the plans-building mirror", Writer: "controller (lease holder), at the first build's ask and at each refresh",
|
||||||
KeptIn: "key-value " + PlansBuildingBucket + ", aged out when not refreshed",
|
KeptIn: "key-value " + PlansBuildingBucket + ", aged out when not refreshed",
|
||||||
Others: "the build seat's holders read it and take no new check while a key is there (ADR 0483)",
|
Others: "the build seat's holders read it and take no new check while a key is there (ADR 0483)",
|
||||||
Subjects: kvOf(PlansBuildingBucket), Writes: isController},
|
Subjects: kvOf(PlansBuildingBucket), Writes: isController},
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ var designRows = []string{
|
|||||||
"a pull request's check asked",
|
"a pull request's check asked",
|
||||||
"a pull request's check running",
|
"a pull request's check running",
|
||||||
// The plans in their build phase, a bucket the build seat's holders read (ADR 0483 clause 2, issue 1687).
|
// The plans in their build phase, a bucket the build seat's holders read (ADR 0483 clause 2, issue 1687).
|
||||||
"a delivery plan in its build phase",
|
"the plans-building mirror",
|
||||||
"a merge announced",
|
"a merge announced",
|
||||||
"a pull request's head announced",
|
"a pull request's head announced",
|
||||||
"a pull request's merge check",
|
"a pull request's merge check",
|
||||||
|
|||||||
@@ -123,8 +123,41 @@ type CheckSpec struct {
|
|||||||
// controller could not learn them, and the verdict says so.
|
// controller could not learn them, and the verdict says so.
|
||||||
Siblings []Sibling
|
Siblings []Sibling
|
||||||
SiblingsUnknown string
|
SiblingsUnknown string
|
||||||
|
// Held are the approved held values of the check's pull requests (novox/hq ADR 0502 clause 4): written to a file and
|
||||||
|
// handed to the gate, which lays them over the change's composition only. None: no flag, no file.
|
||||||
|
Held []HeldValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HeldValue is one approved config value held for a key an open pull request introduces: the value of Key for Module on
|
||||||
|
// Node (a machine's real name; the gate finds it in the snapshot by its pseudonym). The gate reads this document.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeHeld writes the held values to held.json under root and answers its path; empty, and nothing written, for none.
|
||||||
|
func writeHeld(root string, held []HeldValue) (string, error) {
|
||||||
|
if len(held) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(held)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
file := filepath.Join(root, "held.json")
|
||||||
|
return file, os.WriteFile(file, raw, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldFlag is how the gate is given the held values: only when there are some, so a judge from before --held still runs.
|
||||||
|
const heldFlag = `${MESH_CHECK_HELD:+--held "$MESH_CHECK_HELD"}`
|
||||||
|
|
||||||
// Sibling is one other open pull request of a delivery group (novox/hq ADR 0382): the repository it is in as
|
// Sibling is one other open pull request of a delivery group (novox/hq ADR 0382): the repository it is in as
|
||||||
// owner/repo, its number, its whole head, the branch it merges into, and Dir, the directory the check finds that
|
// owner/repo, its number, its whole head, the branch it merges into, and Dir, the directory the check finds that
|
||||||
// repository under beside it (an entry of Beside).
|
// repository under beside it (an entry of Beside).
|
||||||
@@ -272,6 +305,8 @@ const (
|
|||||||
// EnvGroup is a delivery group's other heads, as JSON, for the gate (novox/hq ADR 0239); empty for a
|
// EnvGroup is a delivery group's other heads, as JSON, for the gate (novox/hq ADR 0239); empty for a
|
||||||
// pull request checked alone.
|
// pull request checked alone.
|
||||||
EnvGroup = "MESH_CHECK_GROUP"
|
EnvGroup = "MESH_CHECK_GROUP"
|
||||||
|
// EnvHeld is the file of the approved held values of the check's pull requests (novox/hq ADR 0502); empty for none.
|
||||||
|
EnvHeld = "MESH_CHECK_HELD"
|
||||||
// EnvTests is a `go test -run` pattern and EnvStoreOnly is 1 when a branch tried before its pull request
|
// EnvTests is a `go test -run` pattern and EnvStoreOnly is 1 when a branch tried before its pull request
|
||||||
// asks for only those tests, or only the store tests (novox/hq issue 686). Never set for a pull request's
|
// asks for only those tests, or only the store tests (novox/hq issue 686). Never set for a pull request's
|
||||||
// check. A script that reads neither runs whole.
|
// check. A script that reads neither runs whole.
|
||||||
@@ -572,6 +607,15 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The approved held values of the check's pull requests, for the gate (novox/hq ADR 0502 clause 4).
|
||||||
|
heldFile, err := writeHeld(root, spec.Held)
|
||||||
|
if err != nil {
|
||||||
|
return CheckVerdict{}, err
|
||||||
|
}
|
||||||
|
if heldFile != "" {
|
||||||
|
say("check", "with %d config value(s) held for its pull request(s)", len(spec.Held))
|
||||||
|
}
|
||||||
|
|
||||||
// Every clone is made: the credential is gone before anything of the check runs (novox/hq issue 462).
|
// Every clone is made: the credential is gone before anything of the check runs (novox/hq issue 462).
|
||||||
forget()
|
forget()
|
||||||
|
|
||||||
@@ -733,7 +777,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
|
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
|
||||||
EnvBeside + "=" + root,
|
EnvBeside + "=" + root,
|
||||||
EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","),
|
EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","),
|
||||||
EnvGroup + "=" + groupFile,
|
EnvGroup + "=" + groupFile, EnvHeld + "=" + heldFile,
|
||||||
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")}
|
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")}
|
||||||
env = append(env, changedEnv...)
|
env = append(env, changedEnv...)
|
||||||
env = append(env, selection(spec)...)
|
env = append(env, selection(spec)...)
|
||||||
@@ -1100,7 +1144,7 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
|
|||||||
if err := running(inToolchain(tree, append(env, EnvVerdict+"="+verdictFile), "sh", "-c",
|
if err := running(inToolchain(tree, append(env, EnvVerdict+"="+verdictFile), "sh", "-c",
|
||||||
`"$MESH_GATE" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" `+
|
`"$MESH_GATE" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" `+
|
||||||
`--repository "$MESH_CHECK_REPOSITORY" --tree . `+gateChangedFlag(env)+` `+
|
`--repository "$MESH_CHECK_REPOSITORY" --tree . `+gateChangedFlag(env)+` `+
|
||||||
`${MESH_CHECK_GROUP:+--group "$MESH_CHECK_GROUP"} --json > "$MESH_CHECK_VERDICT"`)); err != nil {
|
`${MESH_CHECK_GROUP:+--group "$MESH_CHECK_GROUP"} `+heldFlag+` --json > "$MESH_CHECK_VERDICT"`)); err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return "error", "the check was ended during the merge gate"
|
return "error", "the check was ended during the merge gate"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1826/AC2: the held values are written whole to held.json, and the gate is given the flag only when there are some.
|
||||||
|
func TestHeldValuesAreWrittenForTheGateAndNoneWriteNothing(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if file, err := writeHeld(root, nil); err != nil || file != "" {
|
||||||
|
t.Fatalf("none wrote %q %v", file, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "held.json")); err == nil {
|
||||||
|
t.Fatal("none wrote a file")
|
||||||
|
}
|
||||||
|
file, err := writeHeld(root, []HeldValue{{Pull: "novox/mesh-catalog#434", Node: "ace", Module: "n8n", Key: "media-gid",
|
||||||
|
Value: []byte("2000"), Approved: "the operator"}})
|
||||||
|
if err != nil || file != filepath.Join(root, "held.json") {
|
||||||
|
t.Fatalf("wrote %q %v", file, err)
|
||||||
|
}
|
||||||
|
raw, _ := os.ReadFile(file)
|
||||||
|
for _, want := range []string{`"node":"ace"`, `"module":"n8n"`, `"key":"media-gid"`, `"value":2000`} {
|
||||||
|
if !strings.Contains(string(raw), want) {
|
||||||
|
t.Fatalf("%s lacks %s", raw, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(heldFlag, `--held "$MESH_CHECK_HELD"`) || !strings.HasPrefix(heldFlag, "${MESH_CHECK_HELD:+") {
|
||||||
|
t.Fatalf("the flag is %s", heldFlag)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package builder
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -41,6 +42,11 @@ func inItsOwnGroup(cmd *exec.Cmd) {
|
|||||||
cmd.WaitDelay = KillWait
|
cmd.WaitDelay = KillWait
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CgroupParent is the slice every container a build or check starts joins (novox/hq issue 1798), so the node's own
|
||||||
|
// weights and cap bind it: the build-agent module declares the slice with the machine's CPU weight, IO weight and
|
||||||
|
// memory limits, and names it in MESH_BUILD_CGROUP_PARENT. Empty, a container joins the runtime's default.
|
||||||
|
var CgroupParent string
|
||||||
|
|
||||||
// Labelled is a Runner that marks every container a build starts with the build's id, so a kill
|
// Labelled is a Runner that marks every container a build starts with the build's id, so a kill
|
||||||
// finds what outlived the docker client (novox/hq ADR 0219). Applied at the one place every command
|
// finds what outlived the docker client (novox/hq ADR 0219). Applied at the one place every command
|
||||||
// passes rather than at each `docker run` the builder composes: a run added later is labelled too.
|
// passes rather than at each `docker run` the builder composes: a run added later is labelled too.
|
||||||
@@ -55,8 +61,11 @@ func LabelledArgs(name string, args []string, id string) []string {
|
|||||||
if name != "docker" || len(args) == 0 || args[0] != "run" || id == "" {
|
if name != "docker" || len(args) == 0 || args[0] != "run" || id == "" {
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
out := make([]string, 0, len(args)+2)
|
out := make([]string, 0, len(args)+4)
|
||||||
out = append(out, "run", "--label", BuildLabel+"="+id)
|
out = append(out, "run", "--label", BuildLabel+"="+id)
|
||||||
|
if CgroupParent != "" {
|
||||||
|
out = append(out, "--cgroup-parent", CgroupParent)
|
||||||
|
}
|
||||||
return append(out, args[1:]...)
|
return append(out, args[1:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +80,45 @@ func RemoveContainersOf(ctx context.Context, run Runner, id string) (int, error)
|
|||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, "", "docker", append([]string{"rm", "-f"}, ids...)...); err != nil {
|
if err := removeWithVolumes(ctx, run, ids); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return len(ids), nil
|
return len(ids), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MountedVolumes is the template `docker inspect --format` reads the names of a container's volumes with.
|
||||||
|
const MountedVolumes = `{{range .Mounts}}{{if eq .Type "volume"}}{{println .Name}}{{end}}{{end}}`
|
||||||
|
|
||||||
|
// anonymousVolume is the name docker gives a volume it makes for an image's VOLUME line: 64 hex digits.
|
||||||
|
var anonymousVolume = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||||
|
|
||||||
|
// removeWithVolumes removes containers together with their anonymous volumes (novox/hq issue 1772). A throwaway
|
||||||
|
// store declares its data directory a VOLUME, docker makes an anonymous volume for it, and `docker rm -f`
|
||||||
|
// without -v leaves that volume for ever: 1,629 of them, 107 GB, on one build node. So the volumes the
|
||||||
|
// containers mount are read first, the containers are removed with -v, and an anonymous volume still there
|
||||||
|
// afterwards is removed by name. Only a volume one of these containers mounted, and only one with an anonymous
|
||||||
|
// volume's name, is touched: a named volume — an operator's own data — never is. A volume already gone is
|
||||||
|
// no failure.
|
||||||
|
func removeWithVolumes(ctx context.Context, run Runner, ids []string) error {
|
||||||
|
var volumes []string
|
||||||
|
if out, err := run(ctx, "", "docker", append([]string{"inspect", "--format", MountedVolumes}, ids...)...); err == nil {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, name := range strings.Fields(out) {
|
||||||
|
if anonymousVolume.MatchString(name) && !seen[name] {
|
||||||
|
seen[name] = true
|
||||||
|
volumes = append(volumes, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, "", "docker", append([]string{"rm", "-f", "-v"}, ids...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(volumes) > 0 {
|
||||||
|
_, _ = run(ctx, "", "docker", append([]string{"volume", "rm"}, volumes...)...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// A holder that stops mid-build leaves its containers running (novox/hq issue 306).
|
// A holder that stops mid-build leaves its containers running (novox/hq issue 306).
|
||||||
//
|
//
|
||||||
// A build's containers are removed by the build itself, by a kill, or by the next delivery of the same
|
// A build's containers are removed by the build itself, by a kill, or by the next delivery of the same
|
||||||
@@ -106,7 +148,7 @@ func RemoveLeftBehind(ctx context.Context, run Runner) (int, error) {
|
|||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, "", "docker", append([]string{"rm", "-f"}, ids...)...); err != nil {
|
if err := removeWithVolumes(ctx, run, ids); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return len(ids), nil
|
return len(ids), nil
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package builder
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -49,7 +50,8 @@ func TestAKillRemovesTheContainersLabelledWithTheBuild(t *testing.T) {
|
|||||||
if err != nil || n != 2 {
|
if err != nil || n != 2 {
|
||||||
t.Fatalf("%d %v", n, err)
|
t.Fatalf("%d %v", n, err)
|
||||||
}
|
}
|
||||||
if want := []string{"docker ps -aq --filter label=mesh.build=build-3", "docker rm -f c1 c2"}; !reflect.DeepEqual(ran, want) {
|
if want := []string{"docker ps -aq --filter label=mesh.build=build-3", "docker inspect --format " + MountedVolumes + " c1 c2",
|
||||||
|
"docker rm -f -v c1 c2"}; !reflect.DeepEqual(ran, want) {
|
||||||
t.Errorf("ran %v", ran)
|
t.Errorf("ran %v", ran)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +75,7 @@ func TestAStartingHolderRemovesWhatEarlierHoldersLeftAndNothingElse(t *testing.T
|
|||||||
if n != 3 {
|
if n != 3 {
|
||||||
t.Errorf("removed %d", n)
|
t.Errorf("removed %d", n)
|
||||||
}
|
}
|
||||||
if len(ran) != 2 || !reflect.DeepEqual(ran[1], []string{"docker", "rm", "-f", "c1", "c2", "c7"}) {
|
if len(ran) != 3 || !reflect.DeepEqual(ran[2], []string{"docker", "rm", "-f", "-v", "c1", "c2", "c7"}) {
|
||||||
t.Fatalf("ran %v", ran)
|
t.Fatalf("ran %v", ran)
|
||||||
}
|
}
|
||||||
if !strings.Contains(strings.Join(ran[0], " "), "--filter label=mesh.build") {
|
if !strings.Contains(strings.Join(ran[0], " "), "--filter label=mesh.build") {
|
||||||
@@ -90,3 +92,75 @@ func TestAStartingHolderRemovesWhatEarlierHoldersLeftAndNothingElse(t *testing.T
|
|||||||
t.Errorf("with nothing of the seat's: %d %v %v", n, err, ran)
|
t.Errorf("with nothing of the seat's: %d %v %v", n, err, ran)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Issue 1772**: a throwaway store declares its data directory a VOLUME, so docker makes an anonymous volume for
|
||||||
|
// it, and `docker rm -f` without -v leaves that volume behind (1,629 of them, 107 GB, on one build node). Every
|
||||||
|
// container a build or check removes is removed with its anonymous volumes (-v), and any volume the container
|
||||||
|
// mounted that is still there afterwards is removed by name — those, and only those, never a named volume.
|
||||||
|
func TestARemovedContainerTakesItsAnonymousVolumesWithIt(t *testing.T) {
|
||||||
|
anon := strings.Repeat("a", 64)
|
||||||
|
other := strings.Repeat("b", 64)
|
||||||
|
var ran []string
|
||||||
|
run := func(_ context.Context, _ string, name string, args ...string) (string, error) {
|
||||||
|
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||||
|
switch args[0] {
|
||||||
|
case "ps":
|
||||||
|
return "c1\n", nil
|
||||||
|
case "inspect":
|
||||||
|
return anon + "\n" + "compose_data\n" + other + "\n", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if n, err := RemoveContainersOf(context.Background(), run, "build-4"); err != nil || n != 1 {
|
||||||
|
t.Fatalf("%d %v", n, err)
|
||||||
|
}
|
||||||
|
last := ran[len(ran)-1]
|
||||||
|
if want := "docker volume rm " + anon + " " + other; last != want {
|
||||||
|
t.Errorf("last command %q, want %q", last, want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(ran, "\n"), "docker rm -f -v c1") {
|
||||||
|
t.Errorf("ran %v", ran)
|
||||||
|
}
|
||||||
|
if strings.Contains(last, "compose_data") {
|
||||||
|
t.Errorf("a named volume was removed: %q", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A volume already gone when the sweep looks (rm -v took it) is no failure.
|
||||||
|
func TestAVolumeAlreadyGoneIsNoFailure(t *testing.T) {
|
||||||
|
run := func(_ context.Context, _ string, name string, args ...string) (string, error) {
|
||||||
|
switch args[0] {
|
||||||
|
case "ps":
|
||||||
|
return "c1\n", nil
|
||||||
|
case "inspect":
|
||||||
|
return strings.Repeat("c", 64) + "\n", nil
|
||||||
|
case "volume":
|
||||||
|
return "", errors.New("no such volume")
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if n, err := RemoveContainersOf(context.Background(), run, "build-5"); err != nil || n != 1 {
|
||||||
|
t.Fatalf("%d %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A node's build slice limits everything a build or check starts (novox/hq issue 1798): with a parent
|
||||||
|
// set, every `docker run` joins it, so the node's CPU and IO weights and memory cap bind the container;
|
||||||
|
// with none set the arguments are as they always were.
|
||||||
|
func TestEveryContainerJoinsTheNodesBuildSlice(t *testing.T) {
|
||||||
|
was := CgroupParent
|
||||||
|
defer func() { CgroupParent = was }()
|
||||||
|
CgroupParent = "meshbuild.slice"
|
||||||
|
got := LabelledArgs("docker", []string{"run", "--rm", "img", "sh"}, "build-1")
|
||||||
|
want := []string{"run", "--label", "mesh.build=build-1", "--cgroup-parent", "meshbuild.slice", "--rm", "img", "sh"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("1798/AC1: docker run became %v", got)
|
||||||
|
}
|
||||||
|
if got := LabelledArgs("docker", []string{"build", "."}, "build-1"); !reflect.DeepEqual(got, []string{"build", "."}) {
|
||||||
|
t.Errorf("a docker build became %v", got)
|
||||||
|
}
|
||||||
|
CgroupParent = ""
|
||||||
|
if got := LabelledArgs("docker", []string{"run", "img"}, "build-1"); !reflect.DeepEqual(got, []string{"run", "--label", "mesh.build=build-1", "img"}) {
|
||||||
|
t.Errorf("1798/AC1: with no slice, docker run became %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,15 +59,35 @@ func (r Registry) PublishImage(ctx context.Context, localTag, repository string)
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
out, err := r.Run(ctx, "", "docker", "inspect", "--format", "{{index .RepoDigests 0}}", remote)
|
// Every digest the daemon lists for the image, one per line: the image may have been pushed under other names before
|
||||||
|
// (a check's scratch copy, another registry), and the first of them is not the one this push made (novox/hq issue
|
||||||
|
// 1742: a build recorded a digest the store never held).
|
||||||
|
out, err := r.Run(ctx, "", "docker", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", remote)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("pushed %s and cannot read back what it was pinned as: %w", remote, err)
|
return "", fmt.Errorf("pushed %s and cannot read back what it was pinned as: %w", remote, err)
|
||||||
}
|
}
|
||||||
pinned := strings.TrimSpace(out)
|
first, pinned := "", ""
|
||||||
if !strings.Contains(pinned, "@sha256:") {
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if first == "" {
|
||||||
|
first = line
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, remote+"@sha256:") {
|
||||||
|
pinned = line
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pinned == "" {
|
||||||
|
if strings.Contains(first, "@sha256:") {
|
||||||
|
return "", fmt.Errorf("pushed %s, but the daemon lists no digest for that name (only %q): the digest of another "+
|
||||||
|
"repository is not what this registry serves", remote, first)
|
||||||
|
}
|
||||||
// A tag is not a pin. It can be made to point at something else, and this file is applied
|
// A tag is not a pin. It can be made to point at something else, and this file is applied
|
||||||
// on machines with no mesh to ask about anything (novox/hq ADR 0006).
|
// on machines with no mesh to ask about anything (novox/hq ADR 0006).
|
||||||
return "", fmt.Errorf("%s came back as %q, which is not pinned by digest", remote, pinned)
|
return "", fmt.Errorf("%s came back as %q, which is not pinned by digest", remote, first)
|
||||||
}
|
}
|
||||||
return pinned, nil
|
return pinned, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,3 +252,32 @@ func TestAPushedImageComesBackPinned(t *testing.T) {
|
|||||||
t.Fatalf("it did not tag and then push: %v", ran)
|
t.Fatalf("it did not tag and then push: %v", ran)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1742: an image pushed under other names before lists their digests too, and the digest recorded is the one of the name
|
||||||
|
// this push made — never the first listed, which may be another repository's.
|
||||||
|
func TestThePinnedDigestIsTheOneOfTheNamePushed(t *testing.T) {
|
||||||
|
want := "registry.invalid/shell/server@sha256:" + strings.Repeat("b", 64)
|
||||||
|
earlier := "registry.invalid/scratch/shell/server@sha256:" + strings.Repeat("a", 64)
|
||||||
|
r := Registry{Address: "registry.invalid", Run: func(
|
||||||
|
_ context.Context, _, name string, args ...string) (string, error) {
|
||||||
|
if name == "docker" && len(args) > 0 && args[0] == "inspect" {
|
||||||
|
return earlier + "\n" + want + "\n", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}}
|
||||||
|
got, err := r.PublishImage(context.Background(), "local", "shell/server")
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Fatalf("pinned %q (%v); want %q", got, err, want)
|
||||||
|
}
|
||||||
|
// Only another repository's digest is listed: the push is not taken for pinned.
|
||||||
|
r.Run = func(_ context.Context, _, name string, args ...string) (string, error) {
|
||||||
|
if name == "docker" && len(args) > 0 && args[0] == "inspect" {
|
||||||
|
return earlier + "\n", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if _, err := r.PublishImage(context.Background(), "local", "shell/server"); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "no digest for that name") {
|
||||||
|
t.Fatalf("a digest of another repository was taken for the push's own: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func imageContent(ctx context.Context, run Runner, dir, ref string) (string, err
|
|||||||
if id == "" {
|
if id == "" {
|
||||||
return "", errors.New("the container engine named no container for " + ref)
|
return "", errors.New("the container engine named no container for " + ref)
|
||||||
}
|
}
|
||||||
defer func() { _, _ = run(context.WithoutCancel(ctx), dir, "docker", "rm", "--force", id) }()
|
defer func() { _, _ = run(context.WithoutCancel(ctx), dir, "docker", "rm", "--force", "--volumes", id) }()
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of the controller's record mirrors, built from the store (novox/hq ADR 0289, design 54 §1,
|
// The values of the controller's record mirrors, built from the store (novox/hq ADR 0289, design 54 §1,
|
||||||
@@ -179,3 +181,13 @@ func (i *Inventory) PredefinedMirror(ctx context.Context) (map[string][]byte, er
|
|||||||
}
|
}
|
||||||
return map[string][]byte{PredefinedMirrorKey: value}, nil
|
return map[string][]byte{PredefinedMirrorKey: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeclaredOf is a manifest's config declarations as the mirror holds them (DeclaredMirrored), so a pull request's
|
||||||
|
// declarations are told to mesh-config in the one shape it reads the trunk's in (novox/hq ADR 0502 clause 2).
|
||||||
|
func DeclaredOf(settings map[string]catalogue.SettingDeclaration) (map[string]DeclaredMirrored, error) {
|
||||||
|
raw, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return declaredMirrored(raw)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1768/AC1: a request dropped as a duplicate of one another builder runs marks nothing in hand: the claim is not made,
|
||||||
|
// so no mark is left behind to fail every later claim.
|
||||||
|
func TestADuplicateDropIsJudgedBeforeTheClaimSoLeavesNoMark(t *testing.T) {
|
||||||
|
claims := 0
|
||||||
|
m := &natsMachine{
|
||||||
|
opts: MachineOptions{Claim: func() bool { claims++; return true }},
|
||||||
|
duplicate: func(context.Context, BuildRequest) string { return "ace is running it" },
|
||||||
|
}
|
||||||
|
if m.admit(context.Background(), &nats.Msg{}, BuildRequest{ID: "build-1"}) {
|
||||||
|
t.Fatal("a duplicate was admitted")
|
||||||
|
}
|
||||||
|
if claims != 0 {
|
||||||
|
t.Fatalf("a dropped duplicate was claimed %d times, which leaves the in-hand mark behind", claims)
|
||||||
|
}
|
||||||
|
m.duplicate = func(context.Context, BuildRequest) string { return "" }
|
||||||
|
if !m.admit(context.Background(), &nats.Msg{}, BuildRequest{ID: "build-2"}) || claims != 1 {
|
||||||
|
t.Fatalf("a request that is no duplicate was not claimed once: claims=%d", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,6 +218,25 @@ type CheckRequest struct {
|
|||||||
Try bool `json:"try,omitempty"`
|
Try bool `json:"try,omitempty"`
|
||||||
Tests string `json:"tests,omitempty"`
|
Tests string `json:"tests,omitempty"`
|
||||||
StoreOnly bool `json:"store-only,omitempty"`
|
StoreOnly bool `json:"store-only,omitempty"`
|
||||||
|
// HeldConfig are the approved held values of the pull requests this check is of (novox/hq ADR 0502 clause 4): each
|
||||||
|
// a config value the operator approved for a key an open pull request introduces. The merge gate lays them over
|
||||||
|
// their module's values on their node in the composition with the change only, never in the mesh as it is. Empty
|
||||||
|
// from a controller from before it, or when mesh-config held none or did not answer.
|
||||||
|
HeldConfig []HeldConfig `json:"held_config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeldConfig is one approved held value (novox/hq ADR 0502), as mesh-config's `held` action answers it.
|
||||||
|
type HeldConfig 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 says who approved it, in words, as mesh-config states it; By and At are who held it and when.
|
||||||
|
Approved string `json:"approved,omitempty"`
|
||||||
|
By string `json:"by,omitempty"`
|
||||||
|
At string `json:"at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StepTried is the step a build seat says a try's outcome under, in the build's log: one line, a Tried as JSON
|
// StepTried is the step a build seat says a try's outcome under, in the build's log: one line, a Tried as JSON
|
||||||
@@ -292,6 +311,10 @@ type GroupMember struct {
|
|||||||
// Base is the branch it merges into: the build seat merges the head into that trunk's head before the
|
// Base is the branch it merges into: the build seat merges the head into that trunk's head before the
|
||||||
// group is composed (novox/hq ADR 0323 §6). A build seat from before it composes the head alone.
|
// group is composed (novox/hq ADR 0323 §6). A build seat from before it composes the head alone.
|
||||||
Base string `json:"base,omitempty"`
|
Base string `json:"base,omitempty"`
|
||||||
|
// Trunk is the base branch's head the forge announced with the head (novox/hq issue 1106): the trunk commit the
|
||||||
|
// composed verdict names for this member when the build seat named none, so a member whose trunk did not move is
|
||||||
|
// never merged-refused for a verdict that names no trunk.
|
||||||
|
Trunk string `json:"trunk,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Who judges a merge check's gate.
|
// Who judges a merge check's gate.
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ type natsMachine struct {
|
|||||||
// build waits, then the checks of pull requests marked ready, then every other check (novox/hq ADR 0406 §3).
|
// build waits, then the checks of pull requests marked ready, then every other check (novox/hq ADR 0406 §3).
|
||||||
subs []*takeQueue
|
subs []*takeQueue
|
||||||
opts MachineOptions
|
opts MachineOptions
|
||||||
|
// duplicate stands in for runningElsewhere in tests; nil is the real one.
|
||||||
|
duplicate func(context.Context, BuildRequest) string
|
||||||
// gate is what the loop asks of the bus to hold checks while a delivery builds; nil is the bus's (a test sets a fake).
|
// gate is what the loop asks of the bus to hold checks while a delivery builds; nil is the bus's (a test sets a fake).
|
||||||
gate checkGate
|
gate checkGate
|
||||||
// said is the faults of the hold already said, letKey whether this holder created the let-through key for the check it
|
// said is the faults of the hold already said, letKey whether this holder created the let-through key for the check it
|
||||||
@@ -405,18 +407,7 @@ func (m *natsMachine) handOver(ctx context.Context, msg *nats.Msg, q *takeQueue,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **Drained in the moment it was fetched** (novox/hq ADR 0335): handed back at once, as a pause
|
if !m.admit(ctx, msg, request) {
|
||||||
// landing then is.
|
|
||||||
if m.opts.Claim != nil && !m.opts.Claim() {
|
|
||||||
_ = msg.Nak()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// **A duplicate of a check another builder is running is dropped** (issue 1649): an ask redelivered to this machine
|
|
||||||
// because its first holder did not say, in time, that it had it. The running key names that builder and this same
|
|
||||||
// ask; running it a second time would spend a second place on one check.
|
|
||||||
if why := m.runningElsewhere(ctx, request); why != "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s is handed out again while another builder runs it and is dropped: %s\n", request.ID, why)
|
|
||||||
_ = msg.Ack()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A build outlives the acknowledgement window many times over; said while it runs,
|
// A build outlives the acknowledgement window many times over; said while it runs,
|
||||||
@@ -460,6 +451,33 @@ func (m *natsMachine) quiesce(keep *takeQueue) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// admit decides whether a fetched request is run here, and answers the message when it is not.
|
||||||
|
//
|
||||||
|
// **The duplicate is judged before the request is claimed** (issue 1768): a claim marks the build in hand, and a request
|
||||||
|
// dropped after it left the mark behind, so every later claim failed and every ask was handed back at once until all
|
||||||
|
// had spent their deliveries. A request dropped here has marked nothing.
|
||||||
|
//
|
||||||
|
// **Drained in the moment it was fetched** (novox/hq ADR 0335): handed back at once, as a pause landing then is.
|
||||||
|
func (m *natsMachine) admit(ctx context.Context, msg *nats.Msg, request BuildRequest) bool {
|
||||||
|
// **A duplicate of a check another builder is running is dropped** (issue 1649): an ask redelivered to this machine
|
||||||
|
// because its first holder did not say, in time, that it had it. The running key names that builder and this same
|
||||||
|
// ask; running it a second time would spend a second place on one check.
|
||||||
|
elsewhere := m.runningElsewhere
|
||||||
|
if m.duplicate != nil {
|
||||||
|
elsewhere = m.duplicate
|
||||||
|
}
|
||||||
|
if why := elsewhere(ctx, request); why != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s is handed out again while another builder runs it and is dropped: %s\n", request.ID, why)
|
||||||
|
_ = msg.Ack()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.opts.Claim != nil && !m.opts.Claim() {
|
||||||
|
_ = msg.Nak()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// runningElsewhere says why an ask need not run here, or nothing: the bucket of the checks running says another builder
|
// runningElsewhere says why an ask need not run here, or nothing: the bucket of the checks running says another builder
|
||||||
// runs this very ask (its id, its head and its trunk commit). A key that cannot be read runs the check.
|
// runs this very ask (its id, its head and its trunk commit). A key that cannot be read runs the check.
|
||||||
func (m *natsMachine) runningElsewhere(ctx context.Context, r BuildRequest) string {
|
func (m *natsMachine) runningElsewhere(ctx context.Context, r BuildRequest) string {
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ MESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret
|
|||||||
MESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json
|
MESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json
|
||||||
MESH_WORKSPACE=${dir:workspace}
|
MESH_WORKSPACE=${dir:workspace}
|
||||||
MESH_JUDGE_CACHE=${dir:judge-cache}
|
MESH_JUDGE_CACHE=${dir:judge-cache}
|
||||||
|
MESH_BUILD_CGROUP_PARENT=meshbuild.slice
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Managed by the mesh (module build-agent). Replaced on every push; edit the module's settings instead.
|
||||||
|
#
|
||||||
|
# Everything a build or check starts on this machine runs in this slice (novox/hq issue 1798): the build
|
||||||
|
# agent starts every container with --cgroup-parent=meshbuild.slice. The weights only bind while the machine
|
||||||
|
# is busy, so an idle machine still builds at full speed; the memory limits are the part that always binds.
|
||||||
|
[Slice]
|
||||||
|
CPUWeight=${setting:cpu-weight}
|
||||||
|
IOWeight=${setting:io-weight}
|
||||||
|
MemoryHigh=${setting:memory-high}
|
||||||
|
MemoryMax=${setting:memory-max}
|
||||||
@@ -187,3 +187,61 @@ func TestTheAgentReadsTheGatesPullKeysAndTodaysBoth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Builds and checks run below the machine's own work (novox/hq issue 1798): the module declares the slice
|
||||||
|
// every build container joins with its limits as settings that have defaults, and tells the agent its name.
|
||||||
|
func TestTheBuildSliceCarriesTheMachinesLimits(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Config map[string]struct {
|
||||||
|
Default any `json:"default"`
|
||||||
|
} `json:"config"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
slice, err := os.ReadFile("files/meshbuild.slice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, property := range map[string]string{"cpu-weight": "CPUWeight", "io-weight": "IOWeight", "memory-high": "MemoryHigh", "memory-max": "MemoryMax"} {
|
||||||
|
if c, ok := m.Config[key]; !ok || c.Default == nil {
|
||||||
|
t.Errorf("1798/AC2: setting %s is missing or has no default", key)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(slice), property+"=${setting:"+key+"}") {
|
||||||
|
t.Errorf("1798/AC2: the slice does not set %s from %s", property, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var placed, running bool
|
||||||
|
for _, r := range read(t).Resources {
|
||||||
|
placed = placed || (r["type"] == "file" && r["path"] == "/etc/systemd/system/meshbuild.slice")
|
||||||
|
running = running || (r["type"] == "service" && r["unit"] == "meshbuild.slice" && r["state"] == "running")
|
||||||
|
}
|
||||||
|
if !placed || !running {
|
||||||
|
t.Errorf("1798/AC2: the slice must be placed (%v) and running (%v)", placed, running)
|
||||||
|
}
|
||||||
|
// 1798/AC3: a dash in a slice's name nests it in the slice named before the dash, and cgroup v2 weights only
|
||||||
|
// compare siblings, so the name has none and the slice sits at the top level beside user.slice.
|
||||||
|
for _, r := range read(t).Resources {
|
||||||
|
if r["type"] == "service" && r["unit"] == "meshbuild.slice" {
|
||||||
|
if strings.Contains(strings.TrimSuffix(r["unit"].(string), ".slice"), "-") {
|
||||||
|
t.Errorf("1798/AC3: slice name %v nests", r["unit"])
|
||||||
|
}
|
||||||
|
// 1798/AC4: a changed limit reaches systemd only if the unit restarts when its file changes.
|
||||||
|
restart, _ := r["restart-on"].([]any)
|
||||||
|
if len(restart) != 1 || restart[0] != "build-slice" {
|
||||||
|
t.Errorf("1798/AC4: the slice unit does not restart on its file: %v", restart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env, err := os.ReadFile("files/build-agent.env")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(env), "MESH_BUILD_CGROUP_PARENT=meshbuild.slice") {
|
||||||
|
t.Error("1798/AC1: the agent is not told the slice's name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,34 @@
|
|||||||
"module": "build-agent",
|
"module": "build-agent",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"slug": "agent",
|
"slug": "agent",
|
||||||
|
"config": {
|
||||||
|
"cpu-weight": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The CPU weight of everything a build or check starts on this machine, from 1 to 10000 against 100 for an ordinary service or a login session: under load the builds get their share of that, on an idle machine they take what is free (novox/hq issue 1798). The default 20 is about a sixth of the CPU against one competing workload; a desktop may take 10. Changing it restarts the build slice, which stops every build and check running on that machine at that moment; they fail and must be retried.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10000,
|
||||||
|
"default": 20
|
||||||
|
},
|
||||||
|
"io-weight": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The disk IO weight of everything a build or check starts, from 1 to 10000 against 100 for an ordinary service: the default 10 lets the machine's own work go first (novox/hq issue 1798). Changing it restarts the build slice, which stops every build and check running on that machine at that moment; they fail and must be retried.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10000,
|
||||||
|
"default": 10
|
||||||
|
},
|
||||||
|
"memory-high": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The memory above which builds and checks are slowed and made to give memory back, a percentage of the machine's memory or a size (systemd's MemoryHigh): the default 50% leaves half the machine to what it runs (novox/hq issue 1798). Changing it restarts the build slice, which stops every build and check running on that machine at that moment; they fail and must be retried.",
|
||||||
|
"pattern": "([1-9][0-9]?|100)%|[1-9][0-9]*[KMGT]",
|
||||||
|
"default": "50%"
|
||||||
|
},
|
||||||
|
"memory-max": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The memory builds and checks together may never exceed, a percentage or a size (systemd's MemoryMax); a build past it is killed by the kernel and fails, so keep it above memory-high: the default 70% (novox/hq issue 1798). Changing it restarts the build slice, which stops every build and check running on that machine at that moment; they fail and must be retried.",
|
||||||
|
"pattern": "([1-9][0-9]?|100)%|[1-9][0-9]*[KMGT]",
|
||||||
|
"default": "70%"
|
||||||
|
}
|
||||||
|
},
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
@@ -9,7 +37,12 @@
|
|||||||
{
|
{
|
||||||
"name": "node-build-agent",
|
"name": "node-build-agent",
|
||||||
"scope": "node",
|
"scope": "node",
|
||||||
"serves": ["current", "kill", "pause", "resume"]
|
"serves": [
|
||||||
|
"current",
|
||||||
|
"kill",
|
||||||
|
"pause",
|
||||||
|
"resume"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reads": [
|
"reads": [
|
||||||
@@ -48,7 +81,7 @@
|
|||||||
"id": "judge-cache",
|
"id": "judge-cache",
|
||||||
"path": "${dir:judge-cache}",
|
"path": "${dir:judge-cache}",
|
||||||
"class": "cache",
|
"class": "cache",
|
||||||
"why": "the Go cache every merge check's judge is built with and the group judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)"
|
"why": "the Go cache every merge check's judge is built with and the group judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 \u00a76)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -69,6 +102,23 @@
|
|||||||
"type": "directory",
|
"type": "directory",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "build-slice",
|
||||||
|
"type": "file",
|
||||||
|
"path": "/etc/systemd/system/meshbuild.slice",
|
||||||
|
"mode": "0644",
|
||||||
|
"trusted": false,
|
||||||
|
"template": "files/meshbuild.slice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "build-slice-unit",
|
||||||
|
"type": "service",
|
||||||
|
"unit": "meshbuild.slice",
|
||||||
|
"state": "running",
|
||||||
|
"restart-on": [
|
||||||
|
"build-slice"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "agent-env",
|
"id": "agent-env",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
|
|||||||
|
|
||||||
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
|
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
|
||||||
mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
|
mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
|
||||||
mesh-sdk 725590e06e75c67b96ce58d015b3056c2d261249 conformance/events/module-event.json
|
mesh-sdk 580da61a9107f74fbef33388ece982e1b9a2e483 conformance/events/module-event.json
|
||||||
|
|
||||||
To move them, from this repository's root, with the two repositories checked out beside it:
|
To move them, from this repository's root, with the two repositories checked out beside it:
|
||||||
|
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ func settingKeysUsedBy(m Manifest) map[string]bool {
|
|||||||
return used
|
return used
|
||||||
}
|
}
|
||||||
|
|
||||||
// settingKeysAskedOutsideOwners is every key a module's files, units, contributions and served facts
|
// settingKeysAskedOutsideOwners is every key a module's files, units, items, contributions and served facts
|
||||||
// ask for: what a directory's owner may follow (OwnerSettingProblems).
|
// ask for: what a directory's owner may follow (OwnerSettingProblems).
|
||||||
func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
||||||
used := map[string]bool{}
|
used := map[string]bool{}
|
||||||
@@ -354,6 +354,10 @@ func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An item's text, filled from its template (WithTemplatesFrom), places a key as a file's does (issue 1658).
|
||||||
|
for _, it := range m.Items {
|
||||||
|
note(it.Content)
|
||||||
|
}
|
||||||
inValues := func(values map[string]any) {
|
inValues := func(values map[string]any) {
|
||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ func StateProblems(m Manifest) []string {
|
|||||||
// controller's account alone (the writers table); declared as a module's state, they would be asserted a
|
// controller's account alone (the writers table); declared as a module's state, they would be asserted a
|
||||||
// second way and granted to the module's own principal as well. Named here so a read of one is a read of
|
// second way and granted to the module's own principal as well. Named here so a read of one is a read of
|
||||||
// kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing.
|
// kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing.
|
||||||
var ControllerMirrors = []string{"assignments", "declared-config", "predefined", "builds", "rollouts", "checks-running"}
|
// plans-building is the plans in their build phase, read by the checker to hold checks while a delivery builds (ADR 0483
|
||||||
|
// clause 2, issue 1687).
|
||||||
|
var ControllerMirrors = []string{"assignments", "declared-config", "predefined", "builds", "rollouts", "checks-running", "plans-building"}
|
||||||
|
|
||||||
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
|
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
|
||||||
// such state. An absent owner says nothing — a module may be installed long before the one whose
|
// such state. An absent owner says nothing — a module may be installed long before the one whose
|
||||||
|
|||||||
+65
-4
@@ -9,9 +9,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The checker's keys (novox/hq ADR 0447 clauses 1 to 3, ADR 0444, ADR 0446 clause 5, ADR 0453 clause 4): the verdicts of
|
// The checker's keys (novox/hq ADR 0447 clauses 1 to 3, ADR 0444, ADR 0446 clause 5, ADR 0453 clause 4): the verdicts of
|
||||||
// its checks and the passing verdicts it keeps per package and level. The checker writes them from R4c, dark; the
|
// its checks and the passing verdicts it keeps per package and level. The checker writes them from R4c, dark, and
|
||||||
// forge bridge writes the status `mesh/check` from a verdict (unrequired until R4f), the gate reads it from R4f, and
|
// writes the status `mesh/check` it decides as an owed act in its own bucket (CheckerOwedBucket, the gate's owed shape);
|
||||||
// the view and the watchman read them (a running check carries a pulse).
|
// the forge bridge, the only part that calls the forge, performs that act (unrequired until R4f), once per revision of
|
||||||
|
// the key (ADR 0484 clause 2). The gate reads the status from R4f, and the view and the watchman read the verdicts (a
|
||||||
|
// running check carries a pulse).
|
||||||
//
|
//
|
||||||
// **The verdict's key is the group and the heads, not the fingerprints** (the lead's limit for R4a). Research 063
|
// **The verdict's key is the group and the heads, not the fingerprints** (the lead's limit for R4a). Research 063
|
||||||
// section 01 drew `verdicts.<g>.check.<fpset>`; ADR 0447 clause 1 judges a pull request's head merged onto its
|
// section 01 drew `verdicts.<g>.check.<fpset>`; ADR 0447 clause 1 judges a pull request's head merged onto its
|
||||||
@@ -88,6 +90,25 @@ func ParseCheckerVerdictKey(key string) (group, headsHash string, err error) {
|
|||||||
type LevelResult struct {
|
type LevelResult struct {
|
||||||
Run int `json:"run"`
|
Run int `json:"run"`
|
||||||
Kept int `json:"kept"`
|
Kept int `json:"kept"`
|
||||||
|
// Skipped is the tests the run skipped, so a pass never hides them (issue 1251, ADR 0484 clause 7).
|
||||||
|
Skipped int `json:"skipped,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The states of a Composition (ADR 0484 clause 6): the composition check is the assigner's, so until it delivers a
|
||||||
|
// verdict says CompositionNotRun, and a line never says the composition passed on that.
|
||||||
|
const (
|
||||||
|
CompositionNotRun = "not-run"
|
||||||
|
CompositionPass = "pass"
|
||||||
|
CompositionFail = "fail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Composition is what the check of the machines' composition made of a verdict: its State (not-run, pass or fail), the
|
||||||
|
// Words of a refusal, the mesh-sdk commit it composed with and the machines composed (ADR 0482 clause 9, ADR 0484 clause 6).
|
||||||
|
type Composition struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Words string `json:"words,omitempty"`
|
||||||
|
SDK string `json:"sdk,omitempty"`
|
||||||
|
Machines []string `json:"machines,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Took is the time of each part of a check in integer milliseconds (ADR 0447 clause 3): waiting in the queue, cloning,
|
// Took is the time of each part of a check in integer milliseconds (ADR 0447 clause 3): waiting in the queue, cloning,
|
||||||
@@ -123,6 +144,33 @@ type Verdict struct {
|
|||||||
Took Took `json:"took,omitzero"`
|
Took Took `json:"took,omitzero"`
|
||||||
Machines []string `json:"machines,omitempty"`
|
Machines []string `json:"machines,omitempty"`
|
||||||
SDK string `json:"sdk,omitempty"`
|
SDK string `json:"sdk,omitempty"`
|
||||||
|
// Composition is the composition check's result; absent in a value written before it (ADR 0484 clause 6).
|
||||||
|
Composition *Composition `json:"composition,omitempty"`
|
||||||
|
// OverBound names the levels whose running went over their bound; it does not change the State (ADR 0453 clause 9).
|
||||||
|
OverBound []string `json:"over-bound,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composition) check() error {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch c.State {
|
||||||
|
case CompositionNotRun:
|
||||||
|
if c.Words != "" || c.SDK != "" || len(c.Machines) > 0 {
|
||||||
|
return fmt.Errorf("delivery: the verdict's composition is not run and says nothing of words, the mesh-sdk commit or machines")
|
||||||
|
}
|
||||||
|
case CompositionPass:
|
||||||
|
if c.SDK == "" {
|
||||||
|
return fmt.Errorf("delivery: the verdict's composition passed and names no mesh-sdk commit")
|
||||||
|
}
|
||||||
|
case CompositionFail:
|
||||||
|
if c.Words == "" {
|
||||||
|
return fmt.Errorf("delivery: the verdict's composition failed and gives no words")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("delivery: the verdict's composition state %q is not %s, %s or %s", c.State, CompositionNotRun, CompositionPass, CompositionFail)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v Verdict) check() error {
|
func (v Verdict) check() error {
|
||||||
@@ -141,10 +189,23 @@ func (v Verdict) check() error {
|
|||||||
if err := checkLevel(level); err != nil {
|
if err := checkLevel(level); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if r.Run < 0 || r.Kept < 0 {
|
if r.Run < 0 || r.Kept < 0 || r.Skipped < 0 {
|
||||||
return fmt.Errorf("delivery: the verdict's %s level counts a negative number of tests", level)
|
return fmt.Errorf("delivery: the verdict's %s level counts a negative number of tests", level)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := v.Composition.check(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, level := range v.OverBound {
|
||||||
|
if err := checkLevel(level); err != nil {
|
||||||
|
return fmt.Errorf("delivery: the verdict's over-bound names: %w", err)
|
||||||
|
}
|
||||||
|
if seen[level] {
|
||||||
|
return fmt.Errorf("delivery: the verdict's over-bound names the level %s twice", level)
|
||||||
|
}
|
||||||
|
seen[level] = true
|
||||||
|
}
|
||||||
for part, ms := range map[string]int64{"queue": v.Took.Queue, "clone": v.Took.Clone, "unit": v.Took.Unit,
|
for part, ms := range map[string]int64{"queue": v.Took.Queue, "clone": v.Took.Clone, "unit": v.Took.Unit,
|
||||||
"integration-setup": v.Took.IntegrationSetup, "integration-run": v.Took.IntegrationRun, "composition": v.Took.Composition} {
|
"integration-setup": v.Took.IntegrationSetup, "integration-run": v.Took.IntegrationRun, "composition": v.Took.Composition} {
|
||||||
if ms < 0 {
|
if ms < 0 {
|
||||||
|
|||||||
+26
-1
@@ -28,6 +28,18 @@ const (
|
|||||||
AnswersHistory = 5
|
AnswersHistory = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The contexts of the statuses the mesh owes the forge: the gate's, the delivery's view of a head and of a group
|
||||||
|
// (design 47), and the checker's, which is not required until R4f (ADR 0484 clause 2, ADR 0453 clause 4).
|
||||||
|
const (
|
||||||
|
StatusContextMergeGate = "mesh/merge-gate"
|
||||||
|
StatusContextDelivery = "mesh/delivery"
|
||||||
|
StatusContextDeliveryGroup = "mesh/delivery-group"
|
||||||
|
StatusContextRepoCheck = "mesh/repo-check" // the gate's pending status of a recheck (mesh-delivery.recheck)
|
||||||
|
StatusContextCheck = "mesh/check"
|
||||||
|
)
|
||||||
|
|
||||||
|
var statusContexts = []string{StatusContextMergeGate, StatusContextDelivery, StatusContextDeliveryGroup, StatusContextRepoCheck, StatusContextCheck}
|
||||||
|
|
||||||
// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key.
|
// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key.
|
||||||
const AskKindMerge = "merge"
|
const AskKindMerge = "merge"
|
||||||
|
|
||||||
@@ -131,6 +143,16 @@ func AskingID(askKey string, asking int) string {
|
|||||||
return AskID(askKey + "." + strconv.Itoa(asking))
|
return AskID(askKey + "." + strconv.Itoa(asking))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkStatusContext refuses a status context the mesh does not owe the forge.
|
||||||
|
func checkStatusContext(context string) error {
|
||||||
|
for _, c := range statusContexts {
|
||||||
|
if context == c {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("delivery: the status context %q is not one of %s", context, strings.Join(statusContexts, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
func checkOwedAct(act string) (kind, context string, err error) {
|
func checkOwedAct(act string) (kind, context string, err error) {
|
||||||
kind, context, hasContext := strings.Cut(act, "=")
|
kind, context, hasContext := strings.Cut(act, "=")
|
||||||
switch kind {
|
switch kind {
|
||||||
@@ -138,7 +160,7 @@ func checkOwedAct(act string) (kind, context string, err error) {
|
|||||||
if !hasContext {
|
if !hasContext {
|
||||||
return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=<context>", act)
|
return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=<context>", act)
|
||||||
}
|
}
|
||||||
if err := checkName("status context", context); err != nil {
|
if err := checkStatusContext(context); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
return kind, context, nil
|
return kind, context, nil
|
||||||
@@ -345,6 +367,9 @@ func (o Owed) check() error {
|
|||||||
if o.Context == "" {
|
if o.Context == "" {
|
||||||
return fmt.Errorf("delivery: an owed status has no context")
|
return fmt.Errorf("delivery: an owed status has no context")
|
||||||
}
|
}
|
||||||
|
if err := checkStatusContext(o.Context); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if o.Target == "" {
|
if o.Target == "" {
|
||||||
return fmt.Errorf("delivery: an owed status has no target")
|
return fmt.Errorf("delivery: an owed status has no target")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The checker's other buckets (ADR 0484 clauses 2 and 3): owed, the acts it owes the forge, which the forge bridge
|
||||||
|
// performs once per revision of a key, and previews, what the fetch step answered for a head merged onto a trunk commit.
|
||||||
|
const (
|
||||||
|
// CheckerOwedBucket is the checker's bucket of owed acts, in the gate's owed shape: a status with the context
|
||||||
|
// StatusContextCheck for each head it judged. One part writes each key: the checker.
|
||||||
|
CheckerOwedBucket = CheckerModule + "_owed"
|
||||||
|
// CheckerOwedHistory is the revisions the checker's owed bucket keeps per key.
|
||||||
|
CheckerOwedHistory = 5
|
||||||
|
// PreviewsBucket is the checker's bucket of merge previews; the indexer reads it for the candidates' fingerprints.
|
||||||
|
PreviewsBucket = CheckerModule + "_previews"
|
||||||
|
// PreviewsHistory is the revisions the previews bucket keeps per key.
|
||||||
|
PreviewsHistory = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckerOwedKey is the key, in CheckerOwedBucket, of the status the checker owes on a pull request at a head: the
|
||||||
|
// gate's OwedKey with the act `status=mesh/check`, the only act the checker owes.
|
||||||
|
func CheckerOwedKey(repository string, number int, head string) (string, error) {
|
||||||
|
return OwedKey(repository, number, head, OwedStatus+"="+StatusContextCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCheckerOwedKey is the inverse of CheckerOwedKey; it refuses an owed key of any other act.
|
||||||
|
func ParseCheckerOwedKey(key string) (repository string, number int, head string, err error) {
|
||||||
|
repository, number, head, act, err := ParseOwedKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, "", err
|
||||||
|
}
|
||||||
|
if act != OwedStatus+"="+StatusContextCheck {
|
||||||
|
return "", 0, "", fmt.Errorf("delivery: %q is not a checker owed key: the checker owes only status=%s, not %q", key, StatusContextCheck, act)
|
||||||
|
}
|
||||||
|
return repository, number, head, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeCheckerOwed is o as JSON at Version; it refuses what EncodeOwed refuses and any act but a status with the
|
||||||
|
// context StatusContextCheck.
|
||||||
|
func EncodeCheckerOwed(o Owed) ([]byte, error) {
|
||||||
|
if err := checkCheckerOwed(o); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return EncodeOwed(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkCheckerOwed(o Owed) error {
|
||||||
|
if o.Act != OwedStatus || o.Context != StatusContextCheck {
|
||||||
|
return fmt.Errorf("delivery: the checker owes only a status with the context %s, not the act %q with the context %q", StatusContextCheck, o.Act, o.Context)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeCheckerOwed reads an owed act of the checker's bucket.
|
||||||
|
func DecodeCheckerOwed(data []byte) (Owed, error) {
|
||||||
|
o, err := DecodeOwed(data)
|
||||||
|
if err != nil {
|
||||||
|
return Owed{}, err
|
||||||
|
}
|
||||||
|
if err := checkCheckerOwed(o); err != nil {
|
||||||
|
return Owed{}, err
|
||||||
|
}
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewKey is the key, in PreviewsBucket, of a head merged onto a trunk commit: `<repository>.<head>.<trunk>`.
|
||||||
|
func PreviewKey(repository, head, trunk string) (string, error) {
|
||||||
|
repo, err := escapeRepository(repository)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := checkName("head", head); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := checkName("trunk commit", trunk); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return repo + "." + head + "." + trunk, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePreviewKey is the inverse of PreviewKey.
|
||||||
|
func ParsePreviewKey(key string) (repository, head, trunk string, err error) {
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return "", "", "", fmt.Errorf("delivery: %q is not a preview key, <repository>.<head>.<trunk>", key)
|
||||||
|
}
|
||||||
|
repository, err = unescapeRepository(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("delivery: %q is not a preview key: %w", key, err)
|
||||||
|
}
|
||||||
|
if _, err := PreviewKey(repository, parts[1], parts[2]); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("delivery: %q is not a preview key: %w", key, err)
|
||||||
|
}
|
||||||
|
return repository, parts[1], parts[2], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewModule is what the preview holds of one module directory: the tree sha of the directory, the digest of its
|
||||||
|
// manifest (the key in the manifests bucket) and the hex sha256 of its lock files by path relative to it.
|
||||||
|
type PreviewModule struct {
|
||||||
|
Tree string `json:"tree"`
|
||||||
|
Manifest string `json:"manifest"`
|
||||||
|
Locks map[string]string `json:"locks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge
|
||||||
|
// commit and, for each module directory of the merge, a PreviewModule. A head that does not merge is a preview too: it
|
||||||
|
// names the files in Conflicts and has no merge commit and no modules (issue 1445).
|
||||||
|
type Preview struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Head string `json:"head"`
|
||||||
|
Trunk string `json:"trunk"`
|
||||||
|
Merge string `json:"merge,omitempty"`
|
||||||
|
Conflicts []string `json:"conflicts,omitempty"`
|
||||||
|
Modules map[string]PreviewModule `json:"modules,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Preview) check() error {
|
||||||
|
if _, err := PreviewKey(p.Repository, p.Head, p.Trunk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, f := range p.Conflicts {
|
||||||
|
if f == "" {
|
||||||
|
return fmt.Errorf("delivery: a preview names a conflicting file with an empty path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.Conflicts) > 0 {
|
||||||
|
if p.Merge != "" || len(p.Modules) > 0 {
|
||||||
|
return fmt.Errorf("delivery: a preview of a head in conflict has no merge commit and no modules")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.Merge == "" {
|
||||||
|
return fmt.Errorf("delivery: a preview names no merge commit")
|
||||||
|
}
|
||||||
|
for dir, m := range p.Modules {
|
||||||
|
if dir == "" {
|
||||||
|
return fmt.Errorf("delivery: a preview names a module with an empty directory")
|
||||||
|
}
|
||||||
|
if m.Tree == "" {
|
||||||
|
return fmt.Errorf("delivery: the preview's module %q has no tree", dir)
|
||||||
|
}
|
||||||
|
if _, err := ManifestsKey(m.Manifest); err != nil {
|
||||||
|
return fmt.Errorf("delivery: the preview's module %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
for path, hash := range m.Locks {
|
||||||
|
if path == "" || hash == "" {
|
||||||
|
return fmt.Errorf("delivery: the lock %q of the preview's module %q has an empty path or hash", path, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodePreview is p as JSON at Version.
|
||||||
|
func EncodePreview(p Preview) ([]byte, error) {
|
||||||
|
if err := p.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.V = Version
|
||||||
|
return json.Marshal(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodePreview reads a preview.
|
||||||
|
func DecodePreview(data []byte) (Preview, error) {
|
||||||
|
var p Preview
|
||||||
|
if err := decode(data, &p); err != nil {
|
||||||
|
return Preview{}, err
|
||||||
|
}
|
||||||
|
if err := p.check(); err != nil {
|
||||||
|
return Preview{}, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SamePreview says whether two previews are the same value.
|
||||||
|
func SamePreview(a, b Preview) bool { return reflect.DeepEqual(a, b) }
|
||||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021153908-725590e06e75
|
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021204452-580da61a9107
|
||||||
## explicit; go 1.26.0
|
## explicit; go 1.26.0
|
||||||
git.novox.be/novox/mesh-sdk/go/asks
|
git.novox.be/novox/mesh-sdk/go/asks
|
||||||
git.novox.be/novox/mesh-sdk/go/catalogue
|
git.novox.be/novox/mesh-sdk/go/catalogue
|
||||||
|
|||||||
Reference in New Issue
Block a user