Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4463991e06 | ||
|
|
4357b3688f | ||
|
|
03927a6f6c | ||
|
|
3d4ad6fefb | ||
|
|
6bf38072ca | ||
|
|
b174d743c0 | ||
|
|
37119ea31f | ||
|
|
af2c6b5e94 | ||
|
|
a929df9b0e | ||
|
|
a3c965b9a0 | ||
|
|
b9ec1919b8 | ||
|
|
d491356f59 | ||
|
|
47e1819101 | ||
|
|
0e25ed7c5a | ||
|
|
79e2923dc8 | ||
|
|
1f3fdf3111 | ||
|
|
11815c0fb3 | ||
|
|
696300b9e2 | ||
|
|
e7cc315fd0 | ||
|
|
60253c82b7 | ||
|
|
bb8f076cc0 | ||
|
|
bb4d2e1da4 | ||
|
|
a14dc04b7b |
@@ -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})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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), "")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ func servingPlanSaved(ctx context.Context, bus link.Bus, open *stores) func(*inv
|
|||||||
return func(_ *inventory.Inventory, p inventory.Plan) {
|
return func(_ *inventory.Inventory, p inventory.Plan) {
|
||||||
sayPlanMoved(ctx, bus, open.inventory, p)
|
sayPlanMoved(ctx, bus, open.inventory, p)
|
||||||
planKept(p)
|
planKept(p)
|
||||||
|
keepBuildingKey(ctx, p)
|
||||||
if p.WalkSaid == inventory.WalkEnded {
|
if p.WalkSaid == inventory.WalkEnded {
|
||||||
go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p)
|
go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The plans building (novox/hq ADR 0483 clause 2, issue 1687).
|
||||||
|
//
|
||||||
|
// **A key per delivery plan in its build phase, so that the build seat takes no new check while a delivery builds.** The phase
|
||||||
|
// runs from the ask of the plan's first build until every build of its last tier is built, or the plan fails, stops or waits
|
||||||
|
// on the operator. It is derived from the plan alone (buildPhaseOf), so a controller that restarts finds the same answer. The
|
||||||
|
// key is put when the first build is asked (sayBuilding), kept current at every save of the plan (keepBuildingKey) and by a
|
||||||
|
// look every link.PlansBuildingRefresh while this controller holds the lease (keepingPlansBuilding), and deleted at the phase's
|
||||||
|
// end; the bucket's age removes the key of a controller that stopped, so checks are held at most that long.
|
||||||
|
//
|
||||||
|
// The layout is documented in internal/link/holds.go.
|
||||||
|
|
||||||
|
// buildPhaseOf says whether a plan is in its build phase: open, not waiting for the operator's word, with a build asked and a
|
||||||
|
// build of its tiers still to be built. A tier built and sent leaves the plan rolling while later tiers are still to be asked;
|
||||||
|
// it is in the phase still, which is what keeps a check from taking a place between two tiers.
|
||||||
|
func buildPhaseOf(p inventory.Plan) bool {
|
||||||
|
if !p.Open() || p.Waiting() || p.Release != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
asked, unbuilt := false, false
|
||||||
|
for _, tier := range p.Tiers {
|
||||||
|
for _, m := range tier {
|
||||||
|
st := p.Modules[m]
|
||||||
|
if st != nil && st.State != "" {
|
||||||
|
asked = true
|
||||||
|
}
|
||||||
|
if st == nil || (st.State != "built" && st.State != planDeleted) {
|
||||||
|
unbuilt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return asked && unbuilt
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildingSince is when the plan's first build was asked, or fallback when no module says.
|
||||||
|
func buildingSince(p inventory.Plan, fallback time.Time) time.Time {
|
||||||
|
since := time.Time{}
|
||||||
|
for _, st := range p.Modules {
|
||||||
|
if st != nil && st.AskedAt != nil && (since.IsZero() || st.AskedAt.Before(since)) {
|
||||||
|
since = *st.AskedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if since.IsZero() {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return since
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildingStore is the bucket of the plans building.
|
||||||
|
type buildingStore interface {
|
||||||
|
Put(ctx context.Context, plan string, value []byte) error
|
||||||
|
Delete(ctx context.Context, plan string) error
|
||||||
|
Keys(ctx context.Context) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncBuilding makes the bucket say what the plans say: a key for each plan in its build phase, put again, and none for any
|
||||||
|
// other plan, deleted. It returns the faults, each once.
|
||||||
|
func syncBuilding(ctx context.Context, store buildingStore, plans []inventory.Plan, now time.Time) []error {
|
||||||
|
var faults []error
|
||||||
|
live := map[string]bool{}
|
||||||
|
for _, p := range plans {
|
||||||
|
if !buildPhaseOf(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
live[p.ID] = true
|
||||||
|
if err := putBuilding(ctx, store, p.ID, buildingSince(p, now), now); err != nil {
|
||||||
|
faults = append(faults, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys, err := store.Keys(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return append(faults, err)
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if live[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := store.Delete(ctx, key); err != nil {
|
||||||
|
faults = append(faults, fmt.Errorf("the key of %s could not be taken away: %w", key, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return faults
|
||||||
|
}
|
||||||
|
|
||||||
|
func putBuilding(ctx context.Context, store buildingStore, plan string, since, now time.Time) error {
|
||||||
|
body, _ := json.Marshal(link.PlanBuilding{Plan: plan, Since: since.UTC(), Seen: now.UTC()})
|
||||||
|
if err := store.Put(ctx, plan, body); err != nil {
|
||||||
|
return fmt.Errorf("the key of %s could not be put: %w", plan, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// busBuilding is the store on the controller's bucket.
|
||||||
|
type busBuilding struct {
|
||||||
|
conn *nats.Conn
|
||||||
|
mu sync.Mutex
|
||||||
|
kv jetstream.KeyValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *busBuilding) bucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.kv != nil {
|
||||||
|
return b.kv, nil
|
||||||
|
}
|
||||||
|
api, err := jetstream.New(b.conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kv, err := api.KeyValue(ctx, broker.PlansBuildingBucket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the plans building: %w", err)
|
||||||
|
}
|
||||||
|
b.kv = kv
|
||||||
|
return kv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *busBuilding) Put(ctx context.Context, plan string, value []byte) error {
|
||||||
|
kv, err := b.bucket(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = kv.Put(ctx, plan, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *busBuilding) Delete(ctx context.Context, plan string) error {
|
||||||
|
kv, err := b.bucket(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := kv.Delete(ctx, plan); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *busBuilding) Keys(ctx context.Context) ([]string, error) {
|
||||||
|
kv, err := b.bucket(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys, err := kv.Keys(ctx)
|
||||||
|
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return keys, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildingKeys is the store of the serving controller; nil in a process that is not it, which says nothing.
|
||||||
|
var buildingKeys atomic.Pointer[buildingStore]
|
||||||
|
|
||||||
|
func setBuildingStore(s buildingStore) {
|
||||||
|
if s == nil {
|
||||||
|
buildingKeys.Store(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buildingKeys.Store(&s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sayBuilding puts a plan's key: its first build is being asked (or another of its builds). A key that could not be put is
|
||||||
|
// said; the look that follows puts it again.
|
||||||
|
func sayBuilding(ctx context.Context, plan string, since time.Time) {
|
||||||
|
s := buildingKeys.Load()
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := putBuilding(ctx, *s, plan, since, time.Now()); err != nil {
|
||||||
|
fmt.Printf("plans: %v; checks may take a place of %s until the next look\n", err, plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepBuildingKey is told each plan this controller keeps: its key put again while it builds and taken away once it does not.
|
||||||
|
func keepBuildingKey(ctx context.Context, p inventory.Plan) {
|
||||||
|
s := buildingKeys.Load()
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if buildPhaseOf(p) {
|
||||||
|
sayBuilding(ctx, p.ID, buildingSince(p, time.Now()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := (*s).Delete(ctx, p.ID); err != nil {
|
||||||
|
fmt.Printf("plans: the key of %s could not be taken away; it ages out: %v\n", p.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepingPlansBuilding looks at the plans every link.PlansBuildingRefresh for as long as this controller serves and holds the
|
||||||
|
// lease, so a key is put again before its age and the key of a plan that left the phase unseen is taken away.
|
||||||
|
func keepingPlansBuilding(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn) {
|
||||||
|
store := &busBuilding{conn: conn}
|
||||||
|
setBuildingStore(store)
|
||||||
|
defer setBuildingStore(nil)
|
||||||
|
tick := time.NewTicker(broker.PlansBuildingRefresh)
|
||||||
|
defer tick.Stop()
|
||||||
|
failing := ""
|
||||||
|
for {
|
||||||
|
if epoch, err := theLease.epoch(ctx); err == nil && epoch != 0 {
|
||||||
|
why := ""
|
||||||
|
if plans, err := inv.OpenPlans(ctx); err != nil {
|
||||||
|
why = fmt.Sprintf("the plans cannot be read: %v", err)
|
||||||
|
} else if faults := syncBuilding(ctx, store, plans, time.Now()); len(faults) > 0 {
|
||||||
|
why = fmt.Sprint(faults)
|
||||||
|
}
|
||||||
|
if why != failing {
|
||||||
|
if why != "" {
|
||||||
|
fmt.Printf("plans: the keys of the plans building cannot be kept: %s\n", why)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("plans: the keys of the plans building are kept again\n")
|
||||||
|
}
|
||||||
|
failing = why
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 1687 (ADR 0483 clause 2): a delivery plan's key lives from the ask of its first build until every build of its last
|
||||||
|
// tier is built, or the plan fails, stops or waits on the operator.
|
||||||
|
|
||||||
|
type fakeBuildingStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
keys map[string]link.PlanBuilding
|
||||||
|
puts map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeBuildingStore() *fakeBuildingStore {
|
||||||
|
return &fakeBuildingStore{keys: map[string]link.PlanBuilding{}, puts: map[string]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBuildingStore) Put(_ context.Context, plan string, value []byte) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var v link.PlanBuilding
|
||||||
|
if err := json.Unmarshal(value, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.keys[plan] = v
|
||||||
|
f.puts[plan]++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBuildingStore) Delete(_ context.Context, plan string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.keys, plan)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBuildingStore) Keys(context.Context) ([]string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for k := range f.keys {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aPlan(id, state string, tiers [][]string, states map[string]string) inventory.Plan {
|
||||||
|
p := inventory.Plan{ID: id, State: state, Tiers: tiers, Modules: map[string]*inventory.PlanModule{}}
|
||||||
|
now := time.Now().Add(-time.Minute)
|
||||||
|
for m, st := range states {
|
||||||
|
pm := &inventory.PlanModule{State: st}
|
||||||
|
if st != "" {
|
||||||
|
pm.AskedAt = &now
|
||||||
|
}
|
||||||
|
p.Modules[m] = pm
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPlanIsInItsBuildPhaseFromTheFirstAskToTheLastBuild(t *testing.T) {
|
||||||
|
tiers := [][]string{{"a", "b"}, {"c"}}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
plan inventory.Plan
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"no build asked yet", aPlan("p", inventory.PlanBuilding, tiers, nil), false},
|
||||||
|
{"the first tier asked", aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "asked", "b": "asked"}), true},
|
||||||
|
// Between two tiers: the first built and sent (rolling), the second not asked yet.
|
||||||
|
{"between two tiers", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built"}), true},
|
||||||
|
{"the last tier asked", aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "built", "b": "built", "c": "asked"}), true},
|
||||||
|
{"every build built", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built", "c": "built"}), false},
|
||||||
|
// A module the plan deletes is done as far as its tiers go (release_plan.go), so it holds no key through the rollout.
|
||||||
|
{"a deleted module is done", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built", "c": planDeleted}), false},
|
||||||
|
{"failed", aPlan("p", inventory.PlanFailed, tiers, map[string]string{"a": "failed", "b": "asked"}), false},
|
||||||
|
{"superseded", aPlan("p", inventory.PlanSuperseded, tiers, map[string]string{"a": "asked"}), false},
|
||||||
|
{"a batch whose window is open", aPlan("p", inventory.PlanAssembling, tiers, map[string]string{"a": "asked"}), false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := buildPhaseOf(c.plan); got != c.want {
|
||||||
|
t.Errorf("%s: in the build phase = %v, want %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waiting := aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "asked"})
|
||||||
|
waiting.Delivery = &inventory.PlanDelivery{Awaits: "the operator's word"}
|
||||||
|
if buildPhaseOf(waiting) {
|
||||||
|
t.Error("a plan that waits on the operator is in its build phase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key's life: put, refreshed, deleted at the phase's end; a plan that left the phase unseen loses its key at the next look.
|
||||||
|
func TestAPlanKeyIsPutRefreshedAndDeletedAtThePhasesEnd(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := newFakeBuildingStore()
|
||||||
|
tiers := [][]string{{"a"}, {"b"}}
|
||||||
|
building := aPlan("plan-1", inventory.PlanBuilding, tiers, map[string]string{"a": "asked"})
|
||||||
|
|
||||||
|
if faults := syncBuilding(ctx, store, []inventory.Plan{building}, time.Now()); len(faults) > 0 {
|
||||||
|
t.Fatal(faults)
|
||||||
|
}
|
||||||
|
if _, ok := store.keys["plan-1"]; !ok {
|
||||||
|
t.Fatal("no key while the first build is asked")
|
||||||
|
}
|
||||||
|
first := store.keys["plan-1"]
|
||||||
|
// Refreshed: put again, the phase's start kept.
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
syncBuilding(ctx, store, []inventory.Plan{building}, time.Now())
|
||||||
|
if store.puts["plan-1"] != 2 || !store.keys["plan-1"].Seen.After(first.Seen) || !store.keys["plan-1"].Since.Equal(first.Since) {
|
||||||
|
t.Errorf("the key was not refreshed with its start kept: %d puts, %+v then %+v", store.puts["plan-1"], first, store.keys["plan-1"])
|
||||||
|
}
|
||||||
|
// Between the tiers: still there.
|
||||||
|
between := aPlan("plan-1", inventory.PlanRolling, tiers, map[string]string{"a": "built"})
|
||||||
|
syncBuilding(ctx, store, []inventory.Plan{between}, time.Now())
|
||||||
|
if _, ok := store.keys["plan-1"]; !ok {
|
||||||
|
t.Error("the key was gone between two tiers")
|
||||||
|
}
|
||||||
|
// A key of another plan that is no longer in the phase.
|
||||||
|
store.keys["plan-0"] = link.PlanBuilding{Plan: "plan-0"}
|
||||||
|
done := aPlan("plan-1", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built"})
|
||||||
|
syncBuilding(ctx, store, []inventory.Plan{done}, time.Now())
|
||||||
|
if len(store.keys) != 0 {
|
||||||
|
t.Errorf("keys remain after the last build was built: %v", store.keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepBuildingKey, told of each save, puts the key while the plan builds and deletes it when the plan fails.
|
||||||
|
func TestASavedPlanPutsOrDeletesItsKey(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := newFakeBuildingStore()
|
||||||
|
var s buildingStore = store
|
||||||
|
buildingKeys.Store(&s)
|
||||||
|
defer buildingKeys.Store(nil)
|
||||||
|
|
||||||
|
p := aPlan("plan-7", inventory.PlanBuilding, [][]string{{"a"}}, map[string]string{"a": "asked"})
|
||||||
|
keepBuildingKey(ctx, p)
|
||||||
|
if _, ok := store.keys["plan-7"]; !ok {
|
||||||
|
t.Fatal("the key was not put when the plan was saved building")
|
||||||
|
}
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
keepBuildingKey(ctx, p)
|
||||||
|
if _, ok := store.keys["plan-7"]; ok {
|
||||||
|
t.Fatal("the key stayed after the plan failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plan's key is there before its first build is asked, so a check cannot take a place between the ask and the plan's save.
|
||||||
|
func TestThePlansKeyIsPutBeforeItsFirstBuildIsAsked(t *testing.T) {
|
||||||
|
store := newFakeBuildingStore()
|
||||||
|
var s buildingStore = store
|
||||||
|
buildingKeys.Store(&s)
|
||||||
|
defer buildingKeys.Store(nil)
|
||||||
|
p, byName := aTierOfThree()
|
||||||
|
keyThere := false
|
||||||
|
was := askABuild
|
||||||
|
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
||||||
|
_, keyThere = store.keys["plan-1"]
|
||||||
|
return "build-x", nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { askABuild = was })
|
||||||
|
askModule(t.Context(), p, "a", byName)
|
||||||
|
if !keyThere {
|
||||||
|
t.Fatal("the plan's key was not there when its first build was asked")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -284,6 +284,8 @@ func serve(ctx context.Context) (err error) {
|
|||||||
go mirroringRecords(ctx, inv, bus.Conn, bus)
|
go mirroringRecords(ctx, inv, bus.Conn, bus)
|
||||||
// And the bucket of the files that travel by digest, judged against what each machine's latest declaration names (ADR 0486 §3).
|
// And the bucket of the files that travel by digest, judged against what each machine's latest declaration names (ADR 0486 §3).
|
||||||
go sweepingDeclarationFiles(ctx, inv, bus.Conn)
|
go sweepingDeclarationFiles(ctx, inv, bus.Conn)
|
||||||
|
// And the key of each plan in its build phase, which holds the build seat's checks while a delivery builds (ADR 0483 clause 2).
|
||||||
|
go keepingPlansBuilding(ctx, inv, bus.Conn)
|
||||||
// Every call carries the lease's epoch, and its record is written only under the lease (novox/hq
|
// Every call carries the lease's epoch, and its record is written only under the lease (novox/hq
|
||||||
// to-be 45 §6).
|
// to-be 45 §6).
|
||||||
link.Calls.UnderLease(func() (uint64, error) { return theLease.epoch(ctx) })
|
link.Calls.UnderLease(func() (uint64, error) { return theLease.epoch(ctx) })
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ type queueAnswer struct {
|
|||||||
Dead int `json:"dead"`
|
Dead int `json:"dead"`
|
||||||
MaxDeliver int `json:"max-deliver"`
|
MaxDeliver int `json:"max-deliver"`
|
||||||
Asks []queuedAnswer `json:"asks"`
|
Asks []queuedAnswer `json:"asks"`
|
||||||
Text string `json:"text"`
|
// HeldFor are the delivery plans building, while which the waiting checks are held (ADR 0483 clause 5); empty when none.
|
||||||
|
HeldFor []string `json:"held-for,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// queuedAnswer is one ask as data. Never what the request carries beyond its source — not `held`, every
|
// queuedAnswer is one ask as data. Never what the request carries beyond its source — not `held`, every
|
||||||
@@ -120,6 +122,9 @@ type queuedAnswer struct {
|
|||||||
// Delivery is the how-manyth time an ask in flight was handed out; more than 1 is a restart
|
// Delivery is the how-manyth time an ask in flight was handed out; more than 1 is a restart
|
||||||
// (novox/hq issue 594).
|
// (novox/hq issue 594).
|
||||||
Delivery int `json:"delivery,omitempty"`
|
Delivery int `json:"delivery,omitempty"`
|
||||||
|
// LetThrough is a check that waited past 30 minutes and is let through while a delivery builds: the next one, or the one
|
||||||
|
// that runs (ADR 0483 clause 5).
|
||||||
|
LetThrough bool `json:"let-through,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// aCommitRef is a ref that names a commit by its full hash (SHA-1 or SHA-256).
|
// aCommitRef is a ref that names a commit by its full hash (SHA-1 or SHA-256).
|
||||||
@@ -130,7 +135,8 @@ var aCommitRef = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`)
|
|||||||
func queueAnswerOf(q link.Queue, now time.Time) queueAnswer {
|
func queueAnswerOf(q link.Queue, now time.Time) queueAnswer {
|
||||||
waiting, running, dead := q.Of(link.AskWaiting), q.Of(link.AskInFlight), q.Of(link.AskDead)
|
waiting, running, dead := q.Of(link.AskWaiting), q.Of(link.AskInFlight), q.Of(link.AskDead)
|
||||||
out := queueAnswer{Seat: q.Seat, Waiting: len(waiting), InFlight: len(running), Dead: len(dead),
|
out := queueAnswer{Seat: q.Seat, Waiting: len(waiting), InFlight: len(running), Dead: len(dead),
|
||||||
MaxDeliver: q.MaxDeliver, Asks: []queuedAnswer{}, Text: queueText(q, now)}
|
MaxDeliver: q.MaxDeliver, Asks: []queuedAnswer{}, HeldFor: q.Hold.Plans, Text: queueText(q, now)}
|
||||||
|
letNext, letRunning := q.LetThroughOf(now)
|
||||||
one := func(a link.QueuedAsk, position int) queuedAnswer {
|
one := func(a link.QueuedAsk, position int) queuedAnswer {
|
||||||
kind := link.KindOf(a.Request)
|
kind := link.KindOf(a.Request)
|
||||||
since := a.AskedAt
|
since := a.AskedAt
|
||||||
@@ -143,7 +149,7 @@ func queueAnswerOf(q link.Queue, now time.Time) queueAnswer {
|
|||||||
}
|
}
|
||||||
return queuedAnswer{ID: a.ID, State: a.State, Kind: kind, Repository: link.WithoutPassword(a.Repository), Path: a.Path,
|
return queuedAnswer{ID: a.ID, State: a.State, Kind: kind, Repository: link.WithoutPassword(a.Repository), Path: a.Path,
|
||||||
Ref: a.Ref, Commit: commit, Machine: a.On, WasOn: a.Was, AskedAt: a.AskedAt, Since: since,
|
Ref: a.Ref, Commit: commit, Machine: a.On, WasOn: a.Was, AskedAt: a.AskedAt, Since: since,
|
||||||
Position: position, Seq: a.Seq, Delivery: a.Delivery}
|
Position: position, Seq: a.Seq, Delivery: a.Delivery, LetThrough: a.ID != "" && (a.ID == letNext || a.ID == letRunning)}
|
||||||
}
|
}
|
||||||
for _, a := range running {
|
for _, a := range running {
|
||||||
out.Asks = append(out.Asks, one(a, 0))
|
out.Asks = append(out.Asks, one(a, 0))
|
||||||
@@ -179,6 +185,11 @@ func queueText(q link.Queue, now time.Time) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
letNext, letRunning := q.LetThroughOf(now)
|
||||||
|
const passed = "waited past 30 minutes, let through"
|
||||||
|
if q.HoldFault != "" {
|
||||||
|
fmt.Fprintf(&b, "why checks wait cannot be read, so none is said held: %s\n", q.HoldFault)
|
||||||
|
}
|
||||||
if len(running) > 0 {
|
if len(running) > 0 {
|
||||||
fmt.Fprintln(&b, "\nin flight:")
|
fmt.Fprintln(&b, "\nin flight:")
|
||||||
for _, a := range running {
|
for _, a := range running {
|
||||||
@@ -193,14 +204,27 @@ func queueText(q link.Queue, now time.Time) string {
|
|||||||
case a.Was != "":
|
case a.Was != "":
|
||||||
where = fmt.Sprintf("handed back by %s, to be handed out again", a.Was)
|
where = fmt.Sprintf("handed back by %s, to be handed out again", a.Was)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, " %-26s %-8s %s — %s, %s (seq %d)\n", a.ID, link.KindOf(a.Request), what(a), where,
|
note := ""
|
||||||
ago(a.AskedAt), a.Seq)
|
if a.ID != "" && a.ID == letRunning {
|
||||||
|
note = " — " + passed
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %-26s %-8s %s — %s, %s (seq %d)%s\n", a.ID, link.KindOf(a.Request), what(a), where,
|
||||||
|
ago(a.AskedAt), a.Seq, note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(waiting) > 0 {
|
if len(waiting) > 0 {
|
||||||
fmt.Fprintln(&b, "\nwaiting, in the order they are handed out:")
|
fmt.Fprintln(&b, "\nwaiting, in the order they are handed out:")
|
||||||
|
// **Why the checks wait** (ADR 0483 clause 5): above them, the plans they are held for.
|
||||||
|
if len(q.Hold.Plans) > 0 && hasCheck(waiting) {
|
||||||
|
fmt.Fprintf(&b, " checks are held while delivery %s builds; the oldest check that waited 30 minutes goes first, one at a time\n",
|
||||||
|
strings.Join(q.Hold.Plans, ", "))
|
||||||
|
}
|
||||||
for _, a := range waiting {
|
for _, a := range waiting {
|
||||||
fmt.Fprintf(&b, " %-26s %-8s %s — %s (seq %d)\n", a.ID, link.KindOf(a.Request), what(a), ago(a.AskedAt), a.Seq)
|
note := ""
|
||||||
|
if a.ID != "" && a.ID == letNext {
|
||||||
|
note = " — " + passed + " next"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %-26s %-8s %s — %s (seq %d)%s\n", a.ID, link.KindOf(a.Request), what(a), ago(a.AskedAt), a.Seq, note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(dead) > 0 {
|
if len(dead) > 0 {
|
||||||
@@ -218,6 +242,16 @@ func queueText(q link.Queue, now time.Time) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasCheck says a check is among the asks.
|
||||||
|
func hasCheck(asks []link.QueuedAsk) bool {
|
||||||
|
for _, a := range asks {
|
||||||
|
if a.Request.Check != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// cancelCommand drops one waiting or dead ask.
|
// cancelCommand drops one waiting or dead ask.
|
||||||
func cancelCommand(ctx context.Context, args []string) error {
|
func cancelCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 1688 / 1652 AC5 (ADR 0483 clause 5): the queue names, above the waiting checks, the plan they are held for, and says of a
|
||||||
|
// check let through that it waited past 30 minutes.
|
||||||
|
|
||||||
|
func aHeldQueue(now time.Time) link.Queue {
|
||||||
|
check := func(id string, ago time.Duration, n int) link.QueuedAsk {
|
||||||
|
return link.QueuedAsk{Seq: uint64(n), ID: id, Repository: "ssh://forge/novox/r", AskedAt: now.Add(-ago), State: link.AskWaiting,
|
||||||
|
Request: link.BuildRequest{ID: id, Check: &link.CheckRequest{Owner: "novox", Repo: "r", Number: n}}}
|
||||||
|
}
|
||||||
|
return link.Queue{Seat: link.TheBuildMachine, Asks: []link.QueuedAsk{
|
||||||
|
check("build-young", 5*time.Minute, 1),
|
||||||
|
check("build-old", 45*time.Minute, 2),
|
||||||
|
check("build-older", 50*time.Minute, 3),
|
||||||
|
}, Hold: link.CheckHold{Plans: []string{"plan-42"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheQueueNamesThePlanTheChecksAreHeldFor(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
text := queueText(aHeldQueue(now), now)
|
||||||
|
heldAt := strings.Index(text, "held while delivery plan-42 builds")
|
||||||
|
firstAt := strings.Index(text, "build-young")
|
||||||
|
if heldAt < 0 || firstAt < 0 || heldAt > firstAt {
|
||||||
|
t.Fatalf("the plan the checks are held for is not named above the waiting checks:\n%s", text)
|
||||||
|
}
|
||||||
|
// Nothing building: nothing held.
|
||||||
|
q := aHeldQueue(now)
|
||||||
|
q.Hold = link.CheckHold{}
|
||||||
|
if strings.Contains(queueText(q, now), "held while") {
|
||||||
|
t.Fatal("checks are said held with no delivery building")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACheckLetThroughIsSaidToHaveWaitedPastThirtyMinutes(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
q := aHeldQueue(now)
|
||||||
|
text := queueText(q, now)
|
||||||
|
var saidOf []string
|
||||||
|
for _, l := range strings.Split(text, "\n") {
|
||||||
|
if strings.Contains(l, "waited past 30 minutes, let through") {
|
||||||
|
saidOf = append(saidOf, strings.Fields(l)[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(saidOf) != 1 || saidOf[0] != "build-older" {
|
||||||
|
t.Fatalf("the check let through next (the oldest) is not the one said: %v\n%s", saidOf, text)
|
||||||
|
}
|
||||||
|
answer := queueAnswerOf(q, now)
|
||||||
|
if len(answer.HeldFor) != 1 || answer.HeldFor[0] != "plan-42" {
|
||||||
|
t.Errorf("held-for = %v", answer.HeldFor)
|
||||||
|
}
|
||||||
|
for _, a := range answer.Asks {
|
||||||
|
if a.LetThrough != (a.ID == "build-older") {
|
||||||
|
t.Errorf("%s let-through = %v", a.ID, a.LetThrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A check in flight under the let-through key is said too.
|
||||||
|
q.Asks = append(q.Asks, link.QueuedAsk{Seq: 9, ID: "build-running", State: link.AskInFlight, On: "ace", AskedAt: now.Add(-time.Hour),
|
||||||
|
Started: now.Add(-time.Minute), Request: link.BuildRequest{ID: "build-running", Check: &link.CheckRequest{Owner: "novox", Repo: "r", Number: 9}}})
|
||||||
|
q.Hold.LetThrough = &link.LetThrough{Builder: "ace"}
|
||||||
|
if !strings.Contains(queueText(q, now), "build-running") || !strings.Contains(queueText(q, now), "waited past 30 minutes, let through\n") {
|
||||||
|
t.Errorf("the check in flight let through is not said:\n%s", queueText(q, now))
|
||||||
|
}
|
||||||
|
// Younger than the bound: none is said.
|
||||||
|
young := aHeldQueue(now)
|
||||||
|
young.Asks = young.Asks[:1]
|
||||||
|
if strings.Contains(queueText(young, now), "waited past") {
|
||||||
|
t.Error("a check of 5 minutes is said to have waited past 30")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -577,6 +577,9 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
|
|||||||
}
|
}
|
||||||
// **The walk takes the check's build of the same key** (novox/hq ADR 0350 §2): its ask says so, with the pull
|
// **The walk takes the check's build of the same key** (novox/hq ADR 0350 §2): its ask says so, with the pull
|
||||||
// requests it answers, whose checks name the part of a differing key.
|
// requests it answers, whose checks name the part of a differing key.
|
||||||
|
// **The plan's key is there before its first build is** (ADR 0483 clause 2): a check taken between the ask and the plan's save
|
||||||
|
// would hold a place for its whole run.
|
||||||
|
sayBuilding(ctx, p.ID, now)
|
||||||
id, err := askABuild(withReuse(ctx, reuseWithVerdicts(ctx, p.ID, reuseAskOf(p), mergedHeadsOf(p))), source,
|
id, err := askABuild(withReuse(ctx, reuseWithVerdicts(ctx, p.ID, reuseAskOf(p), mergedHeadsOf(p))), source,
|
||||||
e.Source.Path, ref)
|
e.Source.Path, ref)
|
||||||
if err != nil {
|
if 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.20261021173436-07a65927c362
|
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
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYg
|
|||||||
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.20261021173436-07a65927c362 h1:Ud/YHmhlm0qdB45LyDvxq7l4x6Bg2Ye0siHK5JFE+kk=
|
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.20261021173436-07a65927c362/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=
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func CheckStreams() []Stream {
|
|||||||
Retention: RetentionWorkQueue,
|
Retention: RetentionWorkQueue,
|
||||||
MaxAge: 7 * 24 * 60 * 60,
|
MaxAge: 7 * 24 * 60 * 60,
|
||||||
MaxMsgsPerSubject: 1,
|
MaxMsgsPerSubject: 1,
|
||||||
|
Direct: true,
|
||||||
Why: "the checks of pull requests marked ready, taken after a delivery build and before any other check " +
|
Why: "the checks of pull requests marked ready, taken after a delivery build and before any other check " +
|
||||||
"(novox/hq ADR 0406 §3); one subject a pull request, the newest request kept, so a newer one replaces a " +
|
"(novox/hq ADR 0406 §3); one subject a pull request, the newest request kept, so a newer one replaces a " +
|
||||||
"request still waiting",
|
"request still waiting",
|
||||||
@@ -56,6 +57,7 @@ func CheckStreams() []Stream {
|
|||||||
Retention: RetentionWorkQueue,
|
Retention: RetentionWorkQueue,
|
||||||
MaxAge: 7 * 24 * 60 * 60,
|
MaxAge: 7 * 24 * 60 * 60,
|
||||||
MaxMsgsPerSubject: 1,
|
MaxMsgsPerSubject: 1,
|
||||||
|
Direct: true,
|
||||||
Why: "every other check, taken last and in the order asked (novox/hq ADR 0406 §3); one subject a pull " +
|
Why: "every other check, taken last and in the order asked (novox/hq ADR 0406 §3); one subject a pull " +
|
||||||
"request, the newest request kept; one left behind for a head already judged is dropped when taken",
|
"request, the newest request kept; one left behind for a head already judged is dropped when taken",
|
||||||
},
|
},
|
||||||
@@ -98,6 +100,13 @@ func holderCheckGrants() []string {
|
|||||||
"$KV."+CheckRunningBucket+".>",
|
"$KV."+CheckRunningBucket+".>",
|
||||||
"$JS.API.STREAM.INFO.KV_"+CheckRunningBucket,
|
"$JS.API.STREAM.INFO.KV_"+CheckRunningBucket,
|
||||||
"$JS.API.DIRECT.GET.KV_"+CheckRunningBucket+".>")
|
"$JS.API.DIRECT.GET.KV_"+CheckRunningBucket+".>")
|
||||||
|
// And, to hold checks while a delivery builds (ADR 0483, issues 1687 and 1688): the bucket of the plans building, **read and
|
||||||
|
// never written** (the controller's alone, so no `$KV.` subject of it is granted), and the oldest waiting ask of each check
|
||||||
|
// queue, read by a direct get that takes nothing from the queue and so counts against no delivery of the ask.
|
||||||
|
pub = append(pub, readGrants(PlansBuildingBucket, "")...)
|
||||||
|
for _, w := range CheckWorkers() {
|
||||||
|
pub = append(pub, "$JS.API.DIRECT.GET."+w.Stream, "$JS.API.DIRECT.GET."+w.Stream+".>")
|
||||||
|
}
|
||||||
return pub
|
return pub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,37 @@ func TestTheBuildAgentReadsAndWatchesThePullRequestsKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue 1687 (ADR 0483 clause 2): a holder of the build seat reads the bucket of the plans building and writes none of it, and
|
||||||
|
// reads the oldest waiting ask of each check queue by a direct get, which takes nothing from the queue.
|
||||||
|
func TestTheBuildSeatsHoldersReadThePlansBuildingAndWriteNone(t *testing.T) {
|
||||||
|
seat := Seat{Name: TheBuildSeat, Scope: "node", Accepts: []string{"build"}, Emits: []string{"built", "started"}}
|
||||||
|
p := Principal{Kind: KindModule, Node: "one", Module: "build-agent", Holds: []Seat{seat}, PasswordHash: "x"}
|
||||||
|
perms, err := PermissionsFor(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stream := "KV_" + PlansBuildingBucket
|
||||||
|
for _, s := range []string{
|
||||||
|
"$JS.API.STREAM.INFO." + stream,
|
||||||
|
"$JS.API.DIRECT.GET." + stream + ".>",
|
||||||
|
"$JS.API.CONSUMER.CREATE." + stream + ".>",
|
||||||
|
"$JS.API.DIRECT.GET.CHECKS_READY",
|
||||||
|
"$JS.API.DIRECT.GET.CHECKS_OTHER",
|
||||||
|
} {
|
||||||
|
has(t, perms.Publish, s)
|
||||||
|
}
|
||||||
|
for _, s := range perms.Publish {
|
||||||
|
if SubjectsOverlap(s, "$KV."+PlansBuildingBucket+".plan-1") {
|
||||||
|
t.Errorf("a holder of the build seat may write %s through %q", PlansBuildingBucket, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the controller alone writes it, as the writers table says.
|
||||||
|
for _, row := range WritersTable {
|
||||||
|
for _, s := range row.Subjects {
|
||||||
|
if s == "$KV."+PlansBuildingBucket+".>" && row.Writes(p, "$KV."+PlansBuildingBucket+".plan-1") {
|
||||||
|
t.Errorf("%q lets the build seat's holder write the plans building", row.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ var (
|
|||||||
// repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of
|
// repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of
|
||||||
// those heads. Written by the controller alone, from the announcement, when it holds the lease.
|
// those heads. Written by the controller alone, from the announcement, when it holds the lease.
|
||||||
DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges")
|
DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges")
|
||||||
|
// PlansBuildingBucket holds one key per delivery plan in its build phase (novox/hq ADR 0483 clause 2, issue 1687): the key
|
||||||
|
// is `<plan id>` (`plan-<digits>`), its value the plan, the time its build phase began and the time of the last refresh.
|
||||||
|
// The controller (lease holder) puts it when the plan's first build is asked, puts it again about every thirty seconds
|
||||||
|
// while the plan builds, and deletes it when the phase ends (every build of the last tier built, or the plan fails, stops
|
||||||
|
// or waits on the operator); the bucket's age, PlansBuildingKeptFor, is how long a key outlives a controller that
|
||||||
|
// stopped. **Read by the build seat's holders and the checker, written by the controller alone**: a holder takes no new
|
||||||
|
// check while any key is there, but for one that waited past ADR 0483's bound. A reader lists the keys and reads none of
|
||||||
|
// their values to decide that (`link.BuildingPlans`).
|
||||||
|
PlansBuildingBucket = BucketName(ControllerSeat, PlansBuilding)
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlansBuilding is the local name of the bucket of the plans building, as a reader names it in `reads`
|
||||||
|
// (`mesh-controller.plans-building`).
|
||||||
|
const PlansBuilding = "plans-building"
|
||||||
|
|
||||||
|
// PlansBuildingKeptFor is the age of a plan-building key: the controller puts it again at PlansBuildingRefresh, so a
|
||||||
|
// controller that stopped leaves checks held at most this long (ADR 0483 clause 2).
|
||||||
|
const (
|
||||||
|
PlansBuildingKeptFor = 2 * time.Minute
|
||||||
|
PlansBuildingRefresh = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are.
|
// DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are.
|
||||||
@@ -133,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
|
||||||
@@ -204,14 +224,14 @@ func IsControllerBucket(bucket string) bool {
|
|||||||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket ||
|
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket ||
|
||||||
bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket ||
|
bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket ||
|
||||||
bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket ||
|
bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket ||
|
||||||
bucket == delivery.HaveBucket
|
bucket == PlansBuildingBucket || bucket == delivery.HaveBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||||
func ControllerBuckets() []string {
|
func ControllerBuckets() []string {
|
||||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
|
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
|
||||||
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket,
|
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket,
|
||||||
DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket}
|
DeliveryMergesBucket, PlansBuildingBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||||
@@ -354,6 +374,22 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("asserting bucket %s: %w", CheckRunningBucket, err)
|
return fmt.Errorf("asserting bucket %s: %w", CheckRunningBucket, err)
|
||||||
}
|
}
|
||||||
|
// **The plans building: keys that age** (ADR 0483 clause 2, issue 1687). One message a key, each refresh a new one, so the
|
||||||
|
// age of the bucket is how long a key outlives a controller that stopped refreshing it.
|
||||||
|
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||||
|
Bucket: PlansBuildingBucket,
|
||||||
|
Description: "the delivery plans in their build phase (novox/hq ADR 0483): `<plan id>` holds the plan and when its " +
|
||||||
|
"build phase began; put when the plan's first build is asked and about every thirty seconds, deleted when the " +
|
||||||
|
"phase ends, gone within two minutes of a controller that stopped; read by the build seat's holders, which " +
|
||||||
|
"take no new check while a key is there; written by the controller alone",
|
||||||
|
History: 1,
|
||||||
|
TTL: PlansBuildingKeptFor,
|
||||||
|
MaxValueSize: 4 << 10,
|
||||||
|
MaxBytes: 8 << 20,
|
||||||
|
Storage: jetstream.FileStorage,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("asserting bucket %s: %w", PlansBuildingBucket, err)
|
||||||
|
}
|
||||||
// **The merges mesh-delivery announced: keys that age** (ADR 0420 §7, issue 1025). One message a key; a week.
|
// **The merges mesh-delivery announced: keys that age** (ADR 0420 §7, issue 1025). One message a key; a week.
|
||||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||||
Bucket: DeliveryMergesBucket,
|
Bucket: DeliveryMergesBucket,
|
||||||
|
|||||||
@@ -424,6 +424,11 @@ var DegradableGrants = []DegradableGrant{
|
|||||||
Reason: "refused: the controller asserts this bucket through the API and writes none of its keys — the build seat's " +
|
Reason: "refused: the controller asserts this bucket through the API and writes none of its keys — the build seat's " +
|
||||||
"holders do (ADR 0406 §3, issue 974)",
|
"holders do (ADR 0406 §3, issue 974)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Subject: "$KV." + PlansBuildingBucket + ".>",
|
||||||
|
Reason: "refused: the controller then says no plan builds, and no check is held while a delivery builds — a check run " +
|
||||||
|
"wrongly costs a delivery a place, a check held wrongly costs a pull request its turn (ADR 0483 clause 2, issue 1687)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Subject: "mesh.seat." + ControllerSeat + ".event.sent",
|
Subject: "mesh.seat." + ControllerSeat + ".event.sent",
|
||||||
Reason: "refused: the controller says a tier sent to a machine only for outsiders, and a send that could not " +
|
Reason: "refused: the controller says a tier sent to a machine only for outsiders, and a send that could not " +
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats-server/v2/server"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A holder of the build seat reads the plans building and the server refuses its write** (novox/hq ADR 0483 clause 2, issue
|
||||||
|
// 1687): on a real server whose users are composed as the controller composes them, the controller makes the bucket and puts a
|
||||||
|
// plan's key; the build agent's credential lists the keys and gets one, and its put of a key is refused with a permissions
|
||||||
|
// violation. It also reads the first waiting ask of a check queue by a direct get, which takes nothing from the queue.
|
||||||
|
func TestNatsTheBuildSeatsHoldersReadThePlansBuildingAndTheServerRefusesTheirWrite(t *testing.T) { // 1687
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seat := Seat{Name: TheBuildSeat, Scope: "node", Accepts: []string{"build"}, Emits: []string{"built", "started"}}
|
||||||
|
users, err := Users(Records{Nodes: []string{"alpha"}, Assigned: map[string][]Declared{
|
||||||
|
"alpha": {{Module: RuntimeModule}, {Module: "build-agent", Holds: []Seat{seat}}},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := range users {
|
||||||
|
users[i].PasswordHash = string(hash)
|
||||||
|
}
|
||||||
|
accounts, err := ComposeAccounts(users)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||||
|
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||||
|
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
opts, err := server.ProcessConfigFile(conf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||||
|
}
|
||||||
|
opts.NoLog, opts.NoSigs = true, true
|
||||||
|
s, err := server.NewServer(opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go s.Start()
|
||||||
|
if !s.ReadyForConnections(10 * time.Second) {
|
||||||
|
t.Fatal("the bus did not come up")
|
||||||
|
}
|
||||||
|
t.Cleanup(s.Shutdown)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
connect := func(user string) (*nats.Conn, chan error) {
|
||||||
|
t.Helper()
|
||||||
|
refused := make(chan error, 8)
|
||||||
|
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||||
|
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refused <- err }))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s cannot connect: %v", user, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(nc.Close)
|
||||||
|
return nc, refused
|
||||||
|
}
|
||||||
|
controller, _ := connect(ControllerName)
|
||||||
|
cj := OnConn(controller)
|
||||||
|
if err := AssertMeshStreams(cj); err != nil {
|
||||||
|
t.Fatalf("the controller cannot make the streams: %v", err)
|
||||||
|
}
|
||||||
|
if err := cj.EnsureControllerBuckets(); err != nil {
|
||||||
|
t.Fatalf("the controller cannot make its buckets: %v", err)
|
||||||
|
}
|
||||||
|
cjs, _ := jetstream.New(controller)
|
||||||
|
ckv, err := cjs.KeyValue(ctx, PlansBuildingBucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ckv.Put(ctx, "plan-1", []byte(`{"plan":"plan-1"}`)); err != nil {
|
||||||
|
t.Fatalf("the controller cannot put a plan's key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
holder, refused := connect("alpha.build-agent")
|
||||||
|
hjs, err := jetstream.New(holder)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kv, err := hjs.KeyValue(ctx, PlansBuildingBucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a holder of the build seat cannot bind %s: %v", PlansBuildingBucket, err)
|
||||||
|
}
|
||||||
|
keys, err := kv.Keys(ctx)
|
||||||
|
if err != nil || len(keys) != 1 || keys[0] != "plan-1" {
|
||||||
|
t.Fatalf("the holder lists %v (%v), want plan-1", keys, err)
|
||||||
|
}
|
||||||
|
if e, err := kv.Get(ctx, "plan-1"); err != nil || e == nil {
|
||||||
|
t.Fatalf("the holder cannot get the key: %v", err)
|
||||||
|
}
|
||||||
|
// Its write is refused by the server.
|
||||||
|
if err := holder.Publish("$KV."+PlansBuildingBucket+".plan-2", []byte(`{}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = holder.Flush()
|
||||||
|
select {
|
||||||
|
case err := <-refused:
|
||||||
|
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||||
|
t.Errorf("the server said %v, want a permissions violation", err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Error("the server did not refuse the holder's put of a plan's key")
|
||||||
|
}
|
||||||
|
if _, err := ckv.Get(ctx, "plan-2"); !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
|
t.Errorf("the refused put left a key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the direct get of a check queue's first waiting ask, which is granted.
|
||||||
|
reply, err := holder.RequestWithContext(ctx, "$JS.API.DIRECT.GET."+ChecksOtherStream, []byte(`{"seq":1,"next_by_subj":"`+
|
||||||
|
CheckSubjectPrefix(TheBuildSeat, false)+`>"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the holder's direct get of %s was not answered: %v", ChecksOtherStream, err)
|
||||||
|
}
|
||||||
|
if reply.Header.Get("Status") != "404" {
|
||||||
|
t.Errorf("an empty queue answered %q, want 404", reply.Header.Get("Status"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_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"] }
|
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,6 +177,10 @@ 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: "the plans-building mirror", Writer: "controller (lease holder), at the first build's ask and at each refresh",
|
||||||
|
KeptIn: "key-value " + PlansBuildingBucket + ", aged out when not refreshed",
|
||||||
|
Others: "the build seat's holders read it and take no new check while a key is there (ADR 0483)",
|
||||||
|
Subjects: kvOf(PlansBuildingBucket), Writes: isController},
|
||||||
{State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)",
|
{State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)",
|
||||||
KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule},
|
KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule},
|
||||||
// A pull request's head, before it merges (novox/hq to-be 45 §9): the same one announcer.
|
// A pull request's head, before it merges (novox/hq to-be 45 §9): the same one announcer.
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ var designRows = []string{
|
|||||||
"builds and their outcomes",
|
"builds and their outcomes",
|
||||||
"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-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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+195
-65
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
@@ -162,11 +163,23 @@ 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 checkGate
|
||||||
|
// said is the faults of the hold already said, letKey whether this holder created the let-through key for the check it
|
||||||
|
// took last (ADR 0483 clause 3).
|
||||||
|
said map[string]bool
|
||||||
|
letKey bool
|
||||||
|
// mu guards the subscriptions of subs, bound and let go of by the taking loop and closed by Close.
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeQueue is one queue a machine pulls from.
|
// takeQueue is one queue a machine pulls from.
|
||||||
type takeQueue struct {
|
type takeQueue struct {
|
||||||
sub *nats.Subscription
|
sub *nats.Subscription
|
||||||
|
// pull is what the queue is fetched through: the subscription once bound (a test sets a fake).
|
||||||
|
pull puller
|
||||||
// kind is what its asks are: a build, a ready check, or another check.
|
// kind is what its asks are: a build, a ready check, or another check.
|
||||||
kind string
|
kind string
|
||||||
// worker is the consumer bound; bound is false until a bind succeeded, for a queue the mesh may not have made yet.
|
// worker is the consumer bound; bound is false until a bind succeeded, for a queue the mesh may not have made yet.
|
||||||
@@ -174,6 +187,11 @@ type takeQueue struct {
|
|||||||
bound bool
|
bound bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// puller is the fetch of a queue.
|
||||||
|
type puller interface {
|
||||||
|
Fetch(batch int, opts ...nats.PullOpt) ([]*nats.Msg, error)
|
||||||
|
}
|
||||||
|
|
||||||
// The kinds of queue a machine takes from, in the order it takes from them.
|
// The kinds of queue a machine takes from, in the order it takes from them.
|
||||||
const (
|
const (
|
||||||
queueBuilds = "builds"
|
queueBuilds = "builds"
|
||||||
@@ -195,6 +213,9 @@ type MachineOptions struct {
|
|||||||
// the request in hand first and reads its drain after, so an ask of `busy` racing the fetch sees one or
|
// the request in hand first and reads its drain after, so an ask of `busy` racing the fetch sees one or
|
||||||
// the other, never neither. Nil claims every one.
|
// the other, never neither. Nil claims every one.
|
||||||
Claim func() bool
|
Claim func() bool
|
||||||
|
// LetThroughAfter is how long a check waits while a delivery builds before it is let through; zero is ADR 0483's thirty
|
||||||
|
// minutes. Set by a test.
|
||||||
|
LetThroughAfter time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// MachineOverNATS takes build work from the current build role.
|
// MachineOverNATS takes build work from the current build role.
|
||||||
@@ -222,6 +243,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (m *natsMachine) Close() {
|
func (m *natsMachine) Close() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
for _, q := range m.subs {
|
for _, q := range m.subs {
|
||||||
if q.sub != nil {
|
if q.sub != nil {
|
||||||
_ = q.sub.Unsubscribe()
|
_ = q.sub.Unsubscribe()
|
||||||
@@ -244,6 +267,9 @@ func (m *natsMachine) Close() {
|
|||||||
// **In this order** (novox/hq ADR 0406 §3): a delivery build, then a check of a pull request marked ready, then any
|
// **In this order** (novox/hq ADR 0406 §3): a delivery build, then a check of a pull request marked ready, then any
|
||||||
// other check, each queue tried without waiting; when all three are empty the first is waited on for a second at most
|
// other check, each queue tried without waiting; when all three are empty the first is waited on for a second at most
|
||||||
// and the three are tried again, so a delivery build is never much more than a second behind and nothing spins.
|
// and the three are tried again, so a delivery build is never much more than a second behind and nothing spins.
|
||||||
|
//
|
||||||
|
// **Unless a delivery builds** (novox/hq ADR 0483): while the controller's bucket of the plans building holds a key, no check queue
|
||||||
|
// is fetched from, but for the one check that waited LetThroughAfter, which goes first (see next).
|
||||||
func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build)) error {
|
func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build)) error {
|
||||||
worker, found := broker.HolderConsumerFor(m.on, "build-agent",
|
worker, found := broker.HolderConsumerFor(m.on, "build-agent",
|
||||||
broker.DeclaredSeat{Name: m.seat, Accepts: []string{"build"}})
|
broker.DeclaredSeat{Name: m.seat, Accepts: []string{"build"}})
|
||||||
@@ -288,6 +314,19 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// The seat's own queue is bound again when a running ask made the machine let go of it (quiesce): a machine that cannot
|
||||||
|
// is not taking, which is said and tried again.
|
||||||
|
if !m.subs[0].bound {
|
||||||
|
if err := m.bind(m.subs[0]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "this machine cannot bind %s again after a build, and tries again: %v\n", m.seat, err)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-time.After(pausedPoll):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if time.Now().After(bindAgain) {
|
if time.Now().After(bindAgain) {
|
||||||
for _, q := range m.subs[1:] {
|
for _, q := range m.subs[1:] {
|
||||||
if !q.bound {
|
if !q.bound {
|
||||||
@@ -312,7 +351,7 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
|
|||||||
// say why (2026-10-03, the first build agents). Asked again.
|
// say why (2026-10-03, the first build agents). Asked again.
|
||||||
continue
|
continue
|
||||||
default:
|
default:
|
||||||
if m.subs[0].sub.IsValid() {
|
if m.subs[0].sub != nil && m.subs[0].sub.IsValid() {
|
||||||
// A transient fault in asking — a reconnect, a slow server — is asked past rather
|
// A transient fault in asking — a reconnect, a slow server — is asked past rather
|
||||||
// than ending the machine; one that outlasts the ack wait redelivers nothing lost.
|
// than ending the machine; one that outlasts the ack wait redelivers nothing lost.
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
@@ -320,70 +359,125 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("the bus stopped delivering build work: %w", err)
|
return fmt.Errorf("the bus stopped delivering build work: %w", err)
|
||||||
}
|
}
|
||||||
// **Paused while the pull was answered** (ADR 0219): "takes no new build" holds even for
|
m.handOver(ctx, msg, q, do)
|
||||||
// the one that arrived in that moment. Handed back at once for another holder — counted as
|
// And the queues it let go of are bound again at once, not after the half minute.
|
||||||
// a delivery, which a pause landing exactly then costs and nothing else does.
|
bindAgain = time.Time{}
|
||||||
if m.opts.Paused != nil && m.opts.Paused() {
|
|
||||||
_ = msg.Nak()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var request BuildRequest
|
|
||||||
if err := json.Unmarshal(msg.Data, &request); err != nil {
|
|
||||||
// Unreadable: terminated rather than retried, because the next attempt reads the same
|
|
||||||
// bytes. Nobody waiting hears an answer, which is right — there was no request.
|
|
||||||
_ = msg.Term()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// **Cancelled in the moment it was fetched** (novox/hq ADR 0219): the controller wrote the
|
|
||||||
// id into the seat's cancelled set before deleting the ask, so one taken in between is
|
|
||||||
// ended here, terminated rather than redelivered, and its outcome said as failed — never
|
|
||||||
// built. A set that cannot be read is said and the ask built: a cancel is a person's
|
|
||||||
// exception, and a holder that refused every build while its grant was missing would
|
|
||||||
// stop the mesh building for it.
|
|
||||||
build := &natsBuild{request: request, msg: msg, on: m.on, js: m.js, seat: m.seat, delivery: deliveryOf(msg)}
|
|
||||||
if cancelled, err := IsCancelled(m.js.Conn(), m.seat, request.ID); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "could not read whether %s was cancelled, so it is built: %v\n", request.ID, err)
|
|
||||||
} else if cancelled {
|
|
||||||
endCancelled(ctx, build)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// **A check left behind is dropped when it is taken** (novox/hq ADR 0406 §3): a request on the third queue for a
|
|
||||||
// head and a trunk commit its pull request's key already says were judged is acknowledged and not run.
|
|
||||||
if q.kind == queueOther {
|
|
||||||
if why := m.judgedAlready(ctx, request); why != "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s was left behind in the check queue and is dropped: %s\n", request.ID, why)
|
|
||||||
_ = msg.Ack()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// **Drained in the moment it was fetched** (novox/hq ADR 0335): handed back at once, as a pause
|
|
||||||
// landing then is.
|
|
||||||
if m.opts.Claim != nil && !m.opts.Claim() {
|
|
||||||
_ = msg.Nak()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// **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()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// A build outlives the acknowledgement window many times over; said while it runs,
|
|
||||||
// as the controller says it for its own long handlers, so the server neither hands
|
|
||||||
// the ask to a second machine nor counts the wait against its deliveries.
|
|
||||||
working := make(chan struct{})
|
|
||||||
go stillWorking(msg, working, heartbeatWhileWorking)
|
|
||||||
// And a check says it is running, to the other builders (ADR 0406 §3): its key put now and at each heartbeat,
|
|
||||||
// deleted when it ends, whatever the outcome.
|
|
||||||
stopRunning := keepRunning(ctx, m.js.Conn(), request, m.on, heartbeatWhileWorking)
|
|
||||||
do(ctx, build)
|
|
||||||
stopRunning()
|
|
||||||
close(working)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handOver settles one fetched ask: dropped, handed back or built. **A check let through keeps its key for as long as it
|
||||||
|
// stays here** (ADR 0483 clause 3): refreshed while it runs and deleted when it ends, however it ends.
|
||||||
|
func (m *natsMachine) handOver(ctx context.Context, msg *nats.Msg, q *takeQueue, do func(context.Context, Build)) {
|
||||||
|
if m.letKey {
|
||||||
|
defer m.keepLetThrough(ctx, heartbeatWhileWorking)()
|
||||||
|
}
|
||||||
|
// **Paused while the pull was answered** (ADR 0219): "takes no new build" holds even for
|
||||||
|
// the one that arrived in that moment. Handed back at once for another holder — counted as
|
||||||
|
// a delivery, which a pause landing exactly then costs and nothing else does.
|
||||||
|
if m.opts.Paused != nil && m.opts.Paused() {
|
||||||
|
_ = msg.Nak()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request BuildRequest
|
||||||
|
if err := json.Unmarshal(msg.Data, &request); err != nil {
|
||||||
|
// Unreadable: terminated rather than retried, because the next attempt reads the same
|
||||||
|
// bytes. Nobody waiting hears an answer, which is right — there was no request.
|
||||||
|
_ = msg.Term()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// **Cancelled in the moment it was fetched** (novox/hq ADR 0219): the controller wrote the
|
||||||
|
// id into the seat's cancelled set before deleting the ask, so one taken in between is
|
||||||
|
// ended here, terminated rather than redelivered, and its outcome said as failed — never
|
||||||
|
// built. A set that cannot be read is said and the ask built: a cancel is a person's
|
||||||
|
// exception, and a holder that refused every build while its grant was missing would
|
||||||
|
// stop the mesh building for it.
|
||||||
|
build := &natsBuild{request: request, msg: msg, on: m.on, js: m.js, seat: m.seat, delivery: deliveryOf(msg)}
|
||||||
|
if cancelled, err := IsCancelled(m.js.Conn(), m.seat, request.ID); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "could not read whether %s was cancelled, so it is built: %v\n", request.ID, err)
|
||||||
|
} else if cancelled {
|
||||||
|
endCancelled(ctx, build)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// **A check left behind is dropped when it is taken** (novox/hq ADR 0406 §3): a request on the third queue for a
|
||||||
|
// head and a trunk commit its pull request's key already says were judged is acknowledged and not run.
|
||||||
|
if q.kind == queueOther {
|
||||||
|
if why := m.judgedAlready(ctx, request); why != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s was left behind in the check queue and is dropped: %s\n", request.ID, why)
|
||||||
|
_ = msg.Ack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !m.admit(ctx, msg, request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A build outlives the acknowledgement window many times over; said while it runs,
|
||||||
|
// as the controller says it for its own long handlers, so the server neither hands
|
||||||
|
// the ask to a second machine nor counts the wait against its deliveries.
|
||||||
|
working := make(chan struct{})
|
||||||
|
go stillWorking(msg, working, heartbeatWhileWorking)
|
||||||
|
// And a check says it is running, to the other builders (ADR 0406 §3): its key put now and at each heartbeat,
|
||||||
|
// deleted when it ends, whatever the outcome.
|
||||||
|
stopRunning := keepRunning(ctx, m.js.Conn(), request, m.on, heartbeatWhileWorking)
|
||||||
|
// **And holds nothing else while it runs** (issue 1747): see quiesce.
|
||||||
|
m.quiesce(q)
|
||||||
|
do(ctx, build)
|
||||||
|
stopRunning()
|
||||||
|
close(working)
|
||||||
|
// **The queue it came from too, once the ask is settled**: a fetch that returned a late message leaves its pull request
|
||||||
|
// open, and the server can deliver one more into the subscription (issue 1747). Take binds every queue again first.
|
||||||
|
m.quiesce(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// quiesce lets go of every queue but the one the running ask came from, for as long as it runs. **A builder never holds a
|
||||||
|
// message it does not start** (issue 1747). A message the server sends after a fetch gave up, within the few milliseconds its
|
||||||
|
// pull request still lived, lands in that queue's subscription and stays there until the next fetch of it: held, never
|
||||||
|
// acknowledged as in progress, for as long as the builder runs its current ask, five or ten minutes for a check, while the
|
||||||
|
// holders that could build it are all busy and a delivery waits behind it. The legacy pull subscription cannot be read
|
||||||
|
// without sending a new pull request, which could take one more, so it cannot be searched for such a message. It is dropped
|
||||||
|
// instead: the subscription is closed, a message in it or on its way is lost to this builder and the server hands it out again
|
||||||
|
// when its worker's wait for an acknowledgement ends (a minute), to any holder that is free, and the queue is bound again
|
||||||
|
// before the next fetch (Take). The queue the running ask came from keeps its subscription, which its acknowledgements use.
|
||||||
|
func (m *natsMachine) quiesce(keep *takeQueue) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, q := range m.subs {
|
||||||
|
if q == keep || !q.bound {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.sub != nil {
|
||||||
|
_ = q.sub.Unsubscribe()
|
||||||
|
}
|
||||||
|
q.sub, q.pull, q.bound = nil, nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
@@ -420,25 +514,39 @@ func duplicateOf(running CheckRunning, found bool, r BuildRequest, me string) st
|
|||||||
// subscribing `…accept.build` against a consumer filtered on `…accept.>` is rejected even though it is narrower. Learned
|
// subscribing `…accept.build` against a consumer filtered on `…accept.>` is rejected even though it is narrower. Learned
|
||||||
// twice now, on two different consumers, which is why it is written down here.
|
// twice now, on two different consumers, which is why it is written down here.
|
||||||
func (m *natsMachine) bind(q *takeQueue) error {
|
func (m *natsMachine) bind(q *takeQueue) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
sub, err := m.js.Context().PullSubscribe(q.worker.Filters[0], q.worker.Name,
|
sub, err := m.js.Context().PullSubscribe(q.worker.Filters[0], q.worker.Name,
|
||||||
nats.Bind(q.worker.Stream, q.worker.Name), nats.ManualAck())
|
nats.Bind(q.worker.Stream, q.worker.Name), nats.ManualAck())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
q.sub, q.bound = sub, true
|
q.sub, q.pull, q.bound = sub, sub, true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// next takes the next request in the order of the queues: each tried for a moment, and when all are empty the first waited
|
// next takes the next request in the order of the queues: each tried for a moment, and when all are empty the first waited
|
||||||
// on for a second. Nothing, with no error, when there was nothing.
|
// on for a second. Nothing, with no error, when there was nothing.
|
||||||
func (m *natsMachine) next(ctx context.Context) (*nats.Msg, *takeQueue, error) {
|
func (m *natsMachine) next(ctx context.Context) (*nats.Msg, *takeQueue, error) {
|
||||||
|
// **While a delivery builds, only delivery builds are taken, but for one check that waited** (novox/hq ADR 0483): the
|
||||||
|
// bucket of the plans building is read before either check queue is fetched from, and a key there holds both. The check
|
||||||
|
// that waited LetThroughAfter goes first, ahead of the builds that wait; the rest of the holders take builds only.
|
||||||
|
if m.seat == broker.TheBuildSeat && len(m.subs) > 1 {
|
||||||
|
if plans := m.building(ctx); len(plans) > 0 {
|
||||||
|
if msg, q := m.letOneThrough(ctx); msg != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "a check that waited %s is let through while %s builds\n", m.letThroughAfter(), whyHeld(plans))
|
||||||
|
return msg, q, nil
|
||||||
|
}
|
||||||
|
return m.nextBuild(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
var last error
|
var last error
|
||||||
for _, q := range m.subs {
|
for _, q := range m.subs {
|
||||||
if !q.bound {
|
if !q.bound {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
trying, end := context.WithTimeout(ctx, checkTakeWait)
|
trying, end := context.WithTimeout(ctx, checkTakeWait)
|
||||||
fetched, err := q.sub.Fetch(1, nats.Context(trying))
|
fetched, err := q.pull.Fetch(1, nats.Context(trying))
|
||||||
end()
|
end()
|
||||||
if len(fetched) > 0 {
|
if len(fetched) > 0 {
|
||||||
return fetched[0], q, nil
|
return fetched[0], q, nil
|
||||||
@@ -454,9 +562,18 @@ func (m *natsMachine) next(ctx context.Context) (*nats.Msg, *takeQueue, error) {
|
|||||||
if last != nil {
|
if last != nil {
|
||||||
return nil, nil, last
|
return nil, nil, last
|
||||||
}
|
}
|
||||||
|
return m.nextBuild(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextBuild waits for a second at most for a delivery build, the seat's own queue.
|
||||||
|
func (m *natsMachine) nextBuild(ctx context.Context) (*nats.Msg, *takeQueue, error) {
|
||||||
|
if !m.subs[0].bound {
|
||||||
|
// Let go of while an ask ran and not bound again yet (Take binds it before it asks): nothing to take from.
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
waiting, end := context.WithTimeout(ctx, takeIdleWait)
|
waiting, end := context.WithTimeout(ctx, takeIdleWait)
|
||||||
defer end()
|
defer end()
|
||||||
fetched, err := m.subs[0].sub.Fetch(1, nats.Context(waiting))
|
fetched, err := m.subs[0].pull.Fetch(1, nats.Context(waiting))
|
||||||
if len(fetched) > 0 {
|
if len(fetched) > 0 {
|
||||||
return fetched[0], m.subs[0], nil
|
return fetched[0], m.subs[0], nil
|
||||||
}
|
}
|
||||||
@@ -619,3 +736,16 @@ func endCancelled(ctx context.Context, b *natsBuild) {
|
|||||||
}
|
}
|
||||||
_ = b.msg.Term()
|
_ = b.msg.Term()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// boundKinds are the kinds of queue this machine holds a subscription to now.
|
||||||
|
func (m *natsMachine) boundKinds() []string {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, q := range m.subs {
|
||||||
|
if q.bound {
|
||||||
|
out = append(out, q.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -287,12 +287,16 @@ type CheckRunning struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RunningKeyOf is the key a check is said running under: its pull request's tokens, or `group.<name>` for a group's
|
// RunningKeyOf is the key a check is said running under: its pull request's tokens, or `group.<name>` for a group's
|
||||||
// composed check; none for a try, which no other request replaces.
|
// composed check; `try.<id>` for a try, which no other request replaces.
|
||||||
func RunningKeyOf(r BuildRequest) (string, bool) {
|
func RunningKeyOf(r BuildRequest) (string, bool) {
|
||||||
c := r.Check
|
c := r.Check
|
||||||
switch {
|
switch {
|
||||||
case c == nil, c.Try:
|
case c == nil:
|
||||||
return "", false
|
return "", false
|
||||||
|
case c.Try:
|
||||||
|
// A try says it runs under its own id, which no other request shares: so a reader of the bucket can tell a try that runs
|
||||||
|
// from a check left waiting for a redelivery (ADR 0483 clause 3).
|
||||||
|
return "try." + tokenOf(r.ID), true
|
||||||
case c.Group != "":
|
case c.Group != "":
|
||||||
return "group." + tokenOf(c.Group), true
|
return "group." + tokenOf(c.Group), true
|
||||||
case c.Number <= 0:
|
case c.Number <= 0:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -595,21 +594,12 @@ func TestABusyMachineTakesNoWaitingAskWhateverItsPullsHold(t *testing.T) {
|
|||||||
case <-time.After(10 * time.Second):
|
case <-time.After(10 * time.Second):
|
||||||
t.Fatal("builder a took nothing")
|
t.Fatal("builder a took nothing")
|
||||||
}
|
}
|
||||||
var other *takeQueue
|
// **A busy machine holds no pull at all** (issues 1649 and 1747): the queues the running ask did not come from are let go of,
|
||||||
for _, q := range a.subs {
|
// so no message, a status message or a late delivery, can sit in one while it runs.
|
||||||
if q.kind == queueOther && q.bound {
|
if kinds := a.boundKinds(); len(kinds) != 1 || kinds[0] != queueBuilds {
|
||||||
other = q
|
t.Fatalf("builder a holds %v while it builds; want only the queue its build came from", kinds)
|
||||||
}
|
|
||||||
}
|
|
||||||
if other == nil {
|
|
||||||
t.Fatal("builder a has no pull on the other checks")
|
|
||||||
}
|
|
||||||
// The queue is empty, so this request is answered at its expiry with a status message that lands in the pull.
|
|
||||||
reply := strings.TrimSuffix(other.sub.Subject, "*") + "late"
|
|
||||||
if err := js.Conn().PublishRequest("$JS.API.CONSUMER.MSG.NEXT."+other.worker.Stream+"."+other.worker.Name, reply,
|
|
||||||
[]byte(`{"batch":1,"expires":1000000}`)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
other := &takeQueue{kind: queueOther, worker: broker.CheckWorkers()[1]}
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
for i, id := range []string{"wait-1", "wait-2"} {
|
for i, id := range []string{"wait-1", "wait-2"} {
|
||||||
if err := asks.Ask(ctx, aPullCheck(id, 20+i, "head-x-000000000", "trunk-1-000000", false)); err != nil {
|
if err := asks.Ask(ctx, aPullCheck(id, 20+i, "head-x-000000000", "trunk-1-000000", false)); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Checks wait while a delivery builds (novox/hq ADR 0483, issues 1652, 1687, 1688), against a real bus.
|
||||||
|
|
||||||
|
// aBusWithTheBuckets is the bus with the build role and the controller's buckets, the bucket of the checks running with an age
|
||||||
|
// of two seconds so a key a holder stopped refreshing is seen to expire.
|
||||||
|
func aBusWithTheBuckets(t *testing.T) (*broker.JetStream, jetstream.KeyValue) {
|
||||||
|
t.Helper()
|
||||||
|
js := aBusWithTheBuildRole(t)
|
||||||
|
api, err := jetstream.New(js.Conn())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := t.Context()
|
||||||
|
if _, err := api.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: broker.CheckRunningBucket, History: 1, TTL: 2 * time.Second}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plans, err := api.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: broker.PlansBuildingBucket, History: 1, TTL: broker.PlansBuildingKeptFor})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return js, plans
|
||||||
|
}
|
||||||
|
|
||||||
|
func aCheckAskedAgo(ago time.Duration, number int, ready bool) BuildRequest {
|
||||||
|
r := aPullCheck(fmt.Sprintf("build-%d", time.Now().Add(-ago).UnixNano()), number, fmt.Sprintf("head-%d-0000000", number), "trunk-0-000000", ready)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1652/AC6: a plan's builds asked in two tiers with a check waiting, the check is handed out only after the last tier's builds,
|
||||||
|
// and not in the gap between the tiers.
|
||||||
|
func TestNatsACheckIsHandedOutOnlyAfterTheLastTierOfAPlan(t *testing.T) {
|
||||||
|
js, plans := aBusWithTheBuckets(t)
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
defer stop()
|
||||||
|
asks := BuildsOn(js, TheBuildMachine)
|
||||||
|
|
||||||
|
if _, err := plans.Put(ctx, "plan-1", []byte(`{"plan":"plan-1"}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := asks.Ask(ctx, aCheckAskedAgo(time.Minute, 7, true)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := asks.Ask(ctx, BuildRequest{ID: "tier-1-a", Repository: "/a"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
took := make(chan string, 16)
|
||||||
|
machine := MachineOverNATS(js, "anchor")
|
||||||
|
defer machine.Close()
|
||||||
|
go func() {
|
||||||
|
_ = machine.Take(ctx, func(ctx context.Context, work Build) {
|
||||||
|
took <- work.Request().ID
|
||||||
|
_ = work.Announce(ctx, BuildResult{ID: work.Request().ID, On: "anchor", Failed: "no"})
|
||||||
|
_ = work.Done()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
expect := func(want string) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case got := <-took:
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("taken %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatalf("%s was not taken", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect("tier-1-a")
|
||||||
|
// The gap between two tiers: the delivery's queue is empty, the plan's key is there, and the check is not taken.
|
||||||
|
select {
|
||||||
|
case got := <-took:
|
||||||
|
t.Fatalf("%s was taken in the gap between two tiers", got)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
if err := asks.Ask(ctx, BuildRequest{ID: "tier-2-a", Repository: "/b"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expect("tier-2-a")
|
||||||
|
select {
|
||||||
|
case got := <-took:
|
||||||
|
t.Fatalf("%s was taken before the plan's last build was built and its key deleted", got)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
// The phase ends: the key is deleted, and the check is handed out.
|
||||||
|
if err := plans.Delete(ctx, "plan-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-took:
|
||||||
|
if got == "tier-1-a" || got == "tier-2-a" {
|
||||||
|
t.Fatalf("a build was taken twice: %s", got)
|
||||||
|
}
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatal("the check was not handed out once the plan's key was deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1688: two holders racing for checks that waited past the bound: exactly one is let through at a time, by a create that fails
|
||||||
|
// while the key is there, and the key is deleted when its check ends, after which the next oldest goes.
|
||||||
|
func TestNatsTwoHoldersRacingForOldChecksLetExactlyOneThrough(t *testing.T) {
|
||||||
|
js, plans := aBusWithTheBuckets(t)
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
defer stop()
|
||||||
|
// The buckets' age is two seconds here, so the keys are refreshed well within it.
|
||||||
|
was := heartbeatWhileWorking
|
||||||
|
heartbeatWhileWorking = 400 * time.Millisecond
|
||||||
|
t.Cleanup(func() { heartbeatWhileWorking = was })
|
||||||
|
asks := BuildsOn(js, TheBuildMachine)
|
||||||
|
if _, err := plans.Put(ctx, "plan-1", []byte(`{"plan":"plan-1"}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, ago := range []time.Duration{50 * time.Minute, 40 * time.Minute, 35 * time.Minute} {
|
||||||
|
if err := asks.Ask(ctx, aCheckAskedAgo(ago, i+1, false)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
running, most := 0, 0
|
||||||
|
started := make(chan string, 8)
|
||||||
|
release := make(chan struct{})
|
||||||
|
do := func(ctx context.Context, work Build) {
|
||||||
|
mu.Lock()
|
||||||
|
running++
|
||||||
|
most = max(most, running)
|
||||||
|
mu.Unlock()
|
||||||
|
started <- work.Request().Check.Repo + fmt.Sprint(work.Request().Check.Number)
|
||||||
|
<-release
|
||||||
|
mu.Lock()
|
||||||
|
running--
|
||||||
|
mu.Unlock()
|
||||||
|
_ = work.Announce(ctx, BuildResult{ID: work.Request().ID, On: "x", Failed: "no"})
|
||||||
|
_ = work.Done()
|
||||||
|
}
|
||||||
|
for _, on := range []string{"ace", "shanks"} {
|
||||||
|
machine := MachineOverNATS(js, on)
|
||||||
|
defer machine.Close()
|
||||||
|
go func() { _ = machine.Take(ctx, do) }()
|
||||||
|
}
|
||||||
|
var order []string
|
||||||
|
for range 3 {
|
||||||
|
select {
|
||||||
|
case id := <-started:
|
||||||
|
order = append(order, id)
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
t.Fatalf("started so far: %v", order)
|
||||||
|
}
|
||||||
|
// While it runs, no second check starts and the key is there.
|
||||||
|
select {
|
||||||
|
case id := <-started:
|
||||||
|
t.Fatalf("%s started beside a check let through", id)
|
||||||
|
case <-time.After(2500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
kv := runningKV(t, js)
|
||||||
|
if _, err := kv.Get(ctx, LetThroughKey); err != nil {
|
||||||
|
t.Fatalf("the let-through key is not there while the check runs: %v", err)
|
||||||
|
}
|
||||||
|
release <- struct{}{}
|
||||||
|
// It is deleted when the check ends: the next check let through creates the key again at once, and a create fails
|
||||||
|
// while the key is there, so its start (the next round) is the proof; after the last one the key stays gone.
|
||||||
|
if len(order) == 3 {
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
if _, err := kv.Get(ctx, LetThroughKey); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the let-through key was not deleted when the last check ended")
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want := []string{"a1", "a2", "a3"}; !reflect.DeepEqual(order, want) {
|
||||||
|
t.Fatalf("checks went %v, want the oldest first %v", order, want)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if most != 1 {
|
||||||
|
t.Fatalf("%d checks ran at once while a delivery built", most)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runningKV(t *testing.T, js *broker.JetStream) jetstream.KeyValue {
|
||||||
|
t.Helper()
|
||||||
|
api, err := jetstream.New(js.Conn())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kv, err := api.KeyValue(t.Context(), broker.CheckRunningBucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return kv
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1688: the create of the let-through key succeeds for exactly one of holders asking at once, and a key its holder stopped
|
||||||
|
// refreshing expires, after which the next create succeeds.
|
||||||
|
func TestNatsOneCreateOfTheLetThroughKeySucceedsAndAStoppedHoldersKeyExpires(t *testing.T) {
|
||||||
|
js, _ := aBusWithTheBuckets(t)
|
||||||
|
gate := busGate{conn: js.Conn()}
|
||||||
|
ctx := t.Context()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
won := 0
|
||||||
|
for i := range 8 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ok, err := gate.TakeLetThrough(ctx, fmt.Sprint("holder-", i))
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
mu.Lock()
|
||||||
|
won++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if won != 1 {
|
||||||
|
t.Fatalf("%d of 8 holders created the let-through key, want exactly one", won)
|
||||||
|
}
|
||||||
|
// The holder stopped: nothing refreshes the key, and the bucket's age (two seconds here) removes it.
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
for {
|
||||||
|
ok, err := gate.TakeLetThrough(ctx, "next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the key of a stopped holder did not expire")
|
||||||
|
}
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
// And a refreshed key does not expire.
|
||||||
|
for range 6 {
|
||||||
|
if err := gate.KeepLetThrough(ctx, "next"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
time.Sleep(700 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if ok, _ := gate.TakeLetThrough(ctx, "other"); ok {
|
||||||
|
t.Fatal("a refreshed key expired")
|
||||||
|
}
|
||||||
|
var v LetThrough
|
||||||
|
e, err := runningKV(t, js).Get(ctx, LetThroughKey)
|
||||||
|
if err != nil || json.Unmarshal(e.Value(), &v) != nil || v.Builder != "next" {
|
||||||
|
t.Fatalf("the key says %+v (%v)", v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1687: the oldest waiting ask is read without being taken: no delivery is counted against it.
|
||||||
|
func TestNatsTheOldestWaitingAskIsReadWithoutTakingIt(t *testing.T) {
|
||||||
|
js, _ := aBusWithTheBuckets(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
asks := BuildsOn(js, TheBuildMachine)
|
||||||
|
old := aCheckAskedAgo(45*time.Minute, 1, false)
|
||||||
|
if err := asks.Ask(ctx, old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gate := busGate{conn: js.Conn()}
|
||||||
|
q := &takeQueue{kind: queueOther, worker: broker.CheckWorkers()[1]}
|
||||||
|
at, found, err := gate.OldestWaiting(ctx, q)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("the first waiting ask was not read: %v %v", found, err)
|
||||||
|
}
|
||||||
|
if want, _ := BuildAskedAt(old.ID); !at.Equal(want) {
|
||||||
|
t.Fatalf("read %v, asked %v", at, want)
|
||||||
|
}
|
||||||
|
for range 3 {
|
||||||
|
if _, _, err := gate.OldestWaiting(ctx, q); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := js.Context().ConsumerInfo(q.worker.Stream, q.worker.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Delivered.Consumer != 0 || info.NumAckPending != 0 || info.NumPending != 1 {
|
||||||
|
t.Fatalf("reading the age took the ask: delivered %d, pending acks %d, waiting %d", info.Delivered.Consumer, info.NumAckPending, info.NumPending)
|
||||||
|
}
|
||||||
|
// And an empty queue reads as none.
|
||||||
|
if _, found, err := gate.OldestWaiting(ctx, &takeQueue{kind: queueReady, worker: broker.CheckWorkers()[0]}); err != nil || found {
|
||||||
|
t.Fatalf("an empty queue read as %v (%v)", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue 1747: a message that reaches a builder's subscription outside a fetch is not held while the builder runs another ask. A
|
||||||
|
// stray check lands in the other queue's subscription of builder A, which then runs a long check; the server hands the stray
|
||||||
|
// out again when its wait for an acknowledgement ends (two seconds here), builder B runs it, and A, finished, does not run it a
|
||||||
|
// second time from the subscription it held.
|
||||||
|
func TestNatsAMessageDeliveredOutsideAFetchIsNotHeldWhileTheBuilderRunsAnotherAsk(t *testing.T) { // 1747
|
||||||
|
js := aBusWithTheBuildRole(t)
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
defer stop()
|
||||||
|
for _, w := range broker.CheckWorkers() {
|
||||||
|
w.AckWaitSeconds = 2
|
||||||
|
if err := js.EnsureConsumer(w); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The long check says it is alive well within the ack wait of two seconds.
|
||||||
|
was := heartbeatWhileWorking
|
||||||
|
heartbeatWhileWorking = 400 * time.Millisecond
|
||||||
|
t.Cleanup(func() { heartbeatWhileWorking = was })
|
||||||
|
asks := BuildsOn(js, TheBuildMachine)
|
||||||
|
long := aPullCheck("build-"+fmt.Sprint(time.Now().UnixNano()), 1, "head-1-0000000", "trunk-0-000000", true)
|
||||||
|
stray := aPullCheck("build-"+fmt.Sprint(time.Now().UnixNano()+1), 2, "head-2-0000000", "trunk-0-000000", false)
|
||||||
|
for _, r := range []BuildRequest{long, stray} {
|
||||||
|
if err := asks.Ask(ctx, r); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
runs := map[string][]string{} // ask id -> the machines that ran it
|
||||||
|
release := make(chan struct{})
|
||||||
|
started := make(chan string, 8)
|
||||||
|
do := func(on string) func(context.Context, Build) {
|
||||||
|
return func(ctx context.Context, work Build) {
|
||||||
|
id := work.Request().ID
|
||||||
|
mu.Lock()
|
||||||
|
runs[id] = append(runs[id], on)
|
||||||
|
mu.Unlock()
|
||||||
|
started <- on + ":" + id
|
||||||
|
if id == long.ID {
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
_ = work.Announce(ctx, BuildResult{ID: id, On: on, Failed: "no"})
|
||||||
|
_ = work.Done()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var a *natsMachine
|
||||||
|
strayLanded := make(chan error, 1)
|
||||||
|
a = MachineOverNATSWith(js, "ace", TheBuildMachine, MachineOptions{Claim: func() bool {
|
||||||
|
// The moment the long check is claimed: the server hands the other queue's waiting check to A's subscription there, as
|
||||||
|
// one that arrives after a fetch gave up does. Done once.
|
||||||
|
select {
|
||||||
|
case <-strayLanded:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
other := a.subs[2]
|
||||||
|
inbox := strings.TrimSuffix(other.sub.Subject, "*") + "stray"
|
||||||
|
body, _ := json.Marshal(map[string]any{"batch": 1, "expires": int64(30 * time.Second)})
|
||||||
|
if err := js.Conn().PublishRequest("$JS.API.CONSUMER.MSG.NEXT."+other.worker.Stream+"."+other.worker.Name, inbox, body); err != nil {
|
||||||
|
strayLanded <- err
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_ = js.Conn().Flush()
|
||||||
|
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
|
||||||
|
if n, _, _ := other.sub.Pending(); n > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strayLanded <- nil
|
||||||
|
return true
|
||||||
|
}}).(*natsMachine)
|
||||||
|
defer a.Close()
|
||||||
|
go func() { _ = a.Take(ctx, do("ace")) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-started:
|
||||||
|
if got != "ace:"+long.ID {
|
||||||
|
t.Fatalf("started %s, want the long check on ace first", got)
|
||||||
|
}
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatal("the long check did not start")
|
||||||
|
}
|
||||||
|
if err := <-strayLanded; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// B, free, runs the stray once the server hands it out again.
|
||||||
|
b := MachineOverNATS(js, "shanks")
|
||||||
|
defer b.Close()
|
||||||
|
go func() { _ = b.Take(ctx, do("shanks")) }()
|
||||||
|
select {
|
||||||
|
case got := <-started:
|
||||||
|
if got != "shanks:"+stray.ID {
|
||||||
|
t.Fatalf("started %s, want the stray on shanks", got)
|
||||||
|
}
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
t.Fatal("the stray was held by the builder that ran the long check and nobody ran it")
|
||||||
|
}
|
||||||
|
// A finishes, and does not run the stray from the subscription it held.
|
||||||
|
release <- struct{}{}
|
||||||
|
select {
|
||||||
|
case got := <-started:
|
||||||
|
t.Fatalf("%s started after the long check: the stray ran twice", got)
|
||||||
|
case <-time.After(6 * time.Second):
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(runs[stray.ID]) != 1 {
|
||||||
|
t.Fatalf("the stray ran on %v, want once", runs[stray.ID])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review of 1688: a young check pending redelivery (handed back by a Nak) stands ahead of a check older than 30 minutes in the
|
||||||
|
// same queue while a plan builds. The let-through fetch would return the young one first, so nothing is fetched: the young
|
||||||
|
// check's delivery count stays 1, it is not handed back every second until it is dead-lettered, and it is not run.
|
||||||
|
func TestNatsAYoungRedeliveryAheadOfAnOldCheckIsNeverFetchedByTheLetThrough(t *testing.T) {
|
||||||
|
js, plans := aBusWithTheBuckets(t)
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
defer stop()
|
||||||
|
asks := BuildsOn(js, TheBuildMachine)
|
||||||
|
young := aCheckAskedAgo(time.Minute, 1, false)
|
||||||
|
if err := asks.Ask(ctx, young); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Someone took it and handed it back: it is pending redelivery.
|
||||||
|
worker := broker.CheckWorkers()[1]
|
||||||
|
sub, err := js.Context().PullSubscribe(worker.Filters[0], worker.Name, nats.Bind(worker.Stream, worker.Name), nats.ManualAck())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := sub.Fetch(1, nats.MaxWait(5*time.Second))
|
||||||
|
if err != nil || len(got) != 1 {
|
||||||
|
t.Fatalf("the young check was not fetched to be handed back: %v", err)
|
||||||
|
}
|
||||||
|
if err := got[0].Nak(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = sub.Unsubscribe()
|
||||||
|
old := aCheckAskedAgo(45*time.Minute, 2, false)
|
||||||
|
if err := asks.Ask(ctx, old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := plans.Put(ctx, "plan-1", []byte(`{"plan":"plan-1"}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
started := make(chan string, 4)
|
||||||
|
machine := MachineOverNATS(js, "ace")
|
||||||
|
defer machine.Close()
|
||||||
|
go func() {
|
||||||
|
_ = machine.Take(ctx, func(ctx context.Context, work Build) {
|
||||||
|
started <- work.Request().ID
|
||||||
|
_ = work.Announce(ctx, BuildResult{ID: work.Request().ID, On: "ace", Failed: "no"})
|
||||||
|
_ = work.Done()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case id := <-started:
|
||||||
|
t.Fatalf("%s was run while the young redelivery stood ahead of it", id)
|
||||||
|
case <-time.After(6 * time.Second):
|
||||||
|
}
|
||||||
|
info, err := js.Context().ConsumerInfo(worker.Stream, worker.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Delivered.Consumer != 1 || info.NumRedelivered != 0 {
|
||||||
|
t.Fatalf("the young check was handed out %d time(s), %d redelivered: the let-through fetched it", info.Delivered.Consumer, info.NumRedelivered)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Checks wait while a delivery builds (novox/hq ADR 0483, issues 1652, 1687 and 1688).
|
||||||
|
//
|
||||||
|
// **The layout other parts read** (ADR 0484 clause 12 has the checker read the same key; a layout shared between modules
|
||||||
|
// belongs in mesh-sdk, and this one is not there yet):
|
||||||
|
//
|
||||||
|
// - bucket `mesh-controller_plans-building` (broker.PlansBuildingBucket): one key per delivery plan in its build phase, named
|
||||||
|
// by the plan's id (`plan-<digits>`), value a PlanBuilding as JSON. Written by the controller (lease holder) alone: put when
|
||||||
|
// the plan's first build is asked, put again about every thirty seconds, deleted when the phase ends; the bucket's age
|
||||||
|
// (two minutes) removes the key of a controller that stopped. A reader lists the keys and needs no value to decide.
|
||||||
|
// - key `let-through` in bucket `mesh-controller_checks-running` (broker.CheckRunningBucket), value a LetThrough as JSON:
|
||||||
|
// created by the holder that takes a check which waited LetThroughAfter, refreshed at each heartbeat of that check, deleted
|
||||||
|
// when it ends. At most one such check runs at a time on the build seat, because the create fails while the key is there.
|
||||||
|
|
||||||
|
// LetThroughAfter is how long a check waits, while a delivery builds, before it is let through (ADR 0483 clause 3).
|
||||||
|
const LetThroughAfter = 30 * time.Minute
|
||||||
|
|
||||||
|
// LetThroughKey is the key of the one check let through, in the bucket of the checks running. It has no dot, so it is no
|
||||||
|
// pull request's key (`<owner>.<repo>.<number>`) and no group's (`group.<name>`).
|
||||||
|
const LetThroughKey = "let-through"
|
||||||
|
|
||||||
|
// PlanBuilding is the value of a plan's key in the bucket of the plans building.
|
||||||
|
type PlanBuilding struct {
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
Since time.Time `json:"since"`
|
||||||
|
Seen time.Time `json:"seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LetThrough is the value of the let-through key: the builder that runs the check and when it took it.
|
||||||
|
type LetThrough struct {
|
||||||
|
Builder string `json:"builder"`
|
||||||
|
Since time.Time `json:"since"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkGate is what a taking loop asks of the bus to hold checks while a delivery builds; a variable of the machine so a test
|
||||||
|
// hands out against fakes.
|
||||||
|
type checkGate interface {
|
||||||
|
// Building is the ids of the plans in their build phase: the keys of the bucket. An error is a bucket that cannot be read.
|
||||||
|
Building(ctx context.Context) ([]string, error)
|
||||||
|
// OldestWaiting is when the first waiting ask of a check queue was asked, read without taking it.
|
||||||
|
OldestWaiting(ctx context.Context, q *takeQueue) (time.Time, bool, error)
|
||||||
|
// TakeLetThrough creates the let-through key for a builder: false when it is there (another builder runs a check let through).
|
||||||
|
TakeLetThrough(ctx context.Context, builder string) (bool, error)
|
||||||
|
// KeepLetThrough puts the key again, and DropLetThrough deletes it.
|
||||||
|
KeepLetThrough(ctx context.Context, builder string) error
|
||||||
|
DropLetThrough(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// busGate is the gate over a connection.
|
||||||
|
type busGate struct{ conn *nats.Conn }
|
||||||
|
|
||||||
|
func (g busGate) Building(ctx context.Context) ([]string, error) {
|
||||||
|
return BuildingPlans(ctx, g.conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildingPlans is the ids of the delivery plans in their build phase, from the controller's bucket. A reader that is not the
|
||||||
|
// build seat's (the checker, ADR 0484 clause 12) calls this. A bucket that cannot be read is an error; none is none building.
|
||||||
|
func BuildingPlans(ctx context.Context, conn *nats.Conn) ([]string, error) {
|
||||||
|
api, err := jetstream.New(conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kv, err := api.KeyValue(ctx, broker.PlansBuildingBucket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the plans building: %w", err)
|
||||||
|
}
|
||||||
|
keys, err := kv.Keys(ctx)
|
||||||
|
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the plans building: %w", err)
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OldestWaiting is when the check a fetch of this queue would return was asked, read without taking anything.
|
||||||
|
//
|
||||||
|
// **It sees redeliveries** (review of issue 1688). A work queue keeps only what is not acknowledged: what the worker handed out
|
||||||
|
// (sequence up to its delivered mark) is running or waiting to be handed out again, and a fetch returns those before anything
|
||||||
|
// new. An ask there that is not said running (a key of the checks running names its build) is such a redelivery. If one is
|
||||||
|
// younger than the bound, the queue's next fetch would return it, and a young check is never let through: the queue is not a
|
||||||
|
// candidate, and nothing is fetched, so nothing is handed back and no delivery is spent. Otherwise the candidate is the oldest
|
||||||
|
// of the redeliveries and the first ask after the delivered mark.
|
||||||
|
func (g busGate) OldestWaiting(ctx context.Context, q *takeQueue) (time.Time, bool, error) {
|
||||||
|
info, err := jsOf(g.conn).ConsumerInfo(q.worker.Stream, q.worker.Name, nats.Context(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false, fmt.Errorf("the %s worker: %w", q.kind, err)
|
||||||
|
}
|
||||||
|
var oldest time.Time
|
||||||
|
note := func(at time.Time) {
|
||||||
|
if oldest.IsZero() || at.Before(oldest) {
|
||||||
|
oldest = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for seq := uint64(1); seq <= info.Delivered.Stream; {
|
||||||
|
data, stored, got, err := g.directGet(ctx, q, seq)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false, err
|
||||||
|
}
|
||||||
|
if got == 0 || got > info.Delivered.Stream {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seq = got + 1
|
||||||
|
var r BuildRequest
|
||||||
|
if json.Unmarshal(data, &r) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
running, ok, err := ReadRunning(ctx, g.conn, r)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false, fmt.Errorf("the checks running: %w", err)
|
||||||
|
}
|
||||||
|
if ok && running.Build == r.ID {
|
||||||
|
continue // running now
|
||||||
|
}
|
||||||
|
at := askedAtOf(data, stored)
|
||||||
|
if time.Since(at) < LetThroughAfter {
|
||||||
|
return time.Time{}, false, nil // a young redelivery is next in line
|
||||||
|
}
|
||||||
|
note(at)
|
||||||
|
}
|
||||||
|
data, stored, got, err := g.directGet(ctx, q, info.Delivered.Stream+1)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false, err
|
||||||
|
}
|
||||||
|
if got != 0 {
|
||||||
|
note(askedAtOf(data, stored))
|
||||||
|
}
|
||||||
|
return oldest, !oldest.IsZero(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// directGet reads the first ask at or after a sequence of a check queue's stream, without taking it: its body, the time the
|
||||||
|
// stream stored it, and its sequence (0 when there is none).
|
||||||
|
func (g busGate) directGet(ctx context.Context, q *takeQueue, seq uint64) ([]byte, string, uint64, error) {
|
||||||
|
body, _ := json.Marshal(map[string]any{"seq": seq, "next_by_subj": q.worker.Filters[0]})
|
||||||
|
reply, err := g.conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET."+q.worker.Stream, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", 0, fmt.Errorf("the asks of the %s queue: %w", q.kind, err)
|
||||||
|
}
|
||||||
|
if st := reply.Header.Get("Status"); st == "404" || st == "408" {
|
||||||
|
return nil, "", 0, nil
|
||||||
|
}
|
||||||
|
got, _ := strconv.ParseUint(reply.Header.Get("Nats-Sequence"), 10, 64)
|
||||||
|
return reply.Data, reply.Header.Get("Nats-Time-Stamp"), got, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// askedAtOf is when a request was asked: its id says (NewBuildID), the stream's own time otherwise, and a request that says
|
||||||
|
// neither is taken as asked now, so it is never let through on a guess.
|
||||||
|
func askedAtOf(data []byte, stored string) time.Time {
|
||||||
|
var r BuildRequest
|
||||||
|
if json.Unmarshal(data, &r) == nil {
|
||||||
|
if at, ok := BuildAskedAt(r.ID); ok {
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at, err := time.Parse(time.RFC3339Nano, stored); err == nil {
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsOf(conn *nats.Conn) nats.JetStreamContext {
|
||||||
|
js, _ := conn.JetStream()
|
||||||
|
return js
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g busGate) TakeLetThrough(ctx context.Context, builder string) (bool, error) {
|
||||||
|
kv, err := runningBucket(ctx, g.conn)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(LetThrough{Builder: builder, Since: time.Now().UTC()})
|
||||||
|
if _, err := kv.Create(ctx, LetThroughKey, body); err != nil {
|
||||||
|
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g busGate) KeepLetThrough(ctx context.Context, builder string) error {
|
||||||
|
kv, err := runningBucket(ctx, g.conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(LetThrough{Builder: builder, Since: time.Now().UTC()})
|
||||||
|
_, err = kv.Put(ctx, LetThroughKey, body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g busGate) DropLetThrough(ctx context.Context) error {
|
||||||
|
kv, err := runningBucket(ctx, g.conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := kv.Delete(ctx, LetThroughKey); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckHold is what a reader of the queue (the controller's own verb) says of why checks wait: the plans building and the check
|
||||||
|
// let through now, if one.
|
||||||
|
type CheckHold struct {
|
||||||
|
Plans []string
|
||||||
|
LetThrough *LetThrough
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCheckHold reads the plans building and the let-through key. A bucket that cannot be read is an error the caller says.
|
||||||
|
func ReadCheckHold(ctx context.Context, conn *nats.Conn) (CheckHold, error) {
|
||||||
|
var h CheckHold
|
||||||
|
plans, err := BuildingPlans(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return h, err
|
||||||
|
}
|
||||||
|
h.Plans = plans
|
||||||
|
kv, err := runningBucket(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return h, err
|
||||||
|
}
|
||||||
|
e, err := kv.Get(ctx, LetThroughKey)
|
||||||
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return h, err
|
||||||
|
}
|
||||||
|
var l LetThrough
|
||||||
|
if json.Unmarshal(e.Value(), &l) == nil {
|
||||||
|
h.LetThrough = &l
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gateOf is the machine's gate: the one a test set, or the bus's.
|
||||||
|
func (m *natsMachine) gateOf() checkGate {
|
||||||
|
if m.gate != nil {
|
||||||
|
return m.gate
|
||||||
|
}
|
||||||
|
return busGate{conn: m.js.Conn()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sayOnce says a fault of the hold the first time it happens and again once it has recovered and happened anew, so a bucket
|
||||||
|
// that cannot be read is in the log once and not every second.
|
||||||
|
func (m *natsMachine) sayOnce(kind string, err error) {
|
||||||
|
if m.said == nil {
|
||||||
|
m.said = map[string]bool{}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
delete(m.said, kind)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.said[kind] {
|
||||||
|
m.said[kind] = true
|
||||||
|
fmt.Fprintf(os.Stderr, "%s; read as empty, so no check is held for it: %v\n", kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// building says whether a delivery builds: any key in the bucket of the plans building. **A bucket that cannot be read is said
|
||||||
|
// and read as empty** (ADR 0483 clause 2): a check wrongly held costs a pull request its turn, a fault that held every check
|
||||||
|
// would stop the mesh checking.
|
||||||
|
func (m *natsMachine) building(ctx context.Context) []string {
|
||||||
|
plans, err := m.gateOf().Building(ctx)
|
||||||
|
m.sayOnce("the bucket of the plans building cannot be read", err)
|
||||||
|
if err != nil {
|
||||||
|
// empty-on-error: ADR 0483 clause 2 — a bucket that cannot be read is said (sayOnce) and read as empty, because a check wrongly held costs a pull request its turn and a fault that held every check would stop the mesh checking.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return plans
|
||||||
|
}
|
||||||
|
|
||||||
|
// letOneThrough takes the oldest waiting check of the two check queues once it waited LetThroughAfter, by the one let-through
|
||||||
|
// key: nil when none waited that long, when another holder runs the check let through, or when the check was taken by
|
||||||
|
// another holder in the moment. The caller owns the key while it returns a message and releases it with dropLetThrough.
|
||||||
|
func (m *natsMachine) letOneThrough(ctx context.Context) (*nats.Msg, *takeQueue) {
|
||||||
|
gate := m.gateOf()
|
||||||
|
var oldest *takeQueue
|
||||||
|
var oldestAt time.Time
|
||||||
|
for _, q := range m.subs[1:] {
|
||||||
|
if !q.bound {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at, found, err := gate.OldestWaiting(ctx, q)
|
||||||
|
m.sayOnce("the first waiting check of the "+q.kind+" queue cannot be read", err)
|
||||||
|
if err != nil || !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if oldest == nil || at.Before(oldestAt) {
|
||||||
|
oldest, oldestAt = q, at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldest == nil || time.Since(oldestAt) < m.letThroughAfter() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
created, err := gate.TakeLetThrough(ctx, m.on)
|
||||||
|
m.sayOnce("the let-through key cannot be taken", err)
|
||||||
|
if err != nil || !created {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
trying, end := context.WithTimeout(ctx, checkTakeWait)
|
||||||
|
defer end()
|
||||||
|
fetched, err := oldest.pull.Fetch(1, nats.Context(trying))
|
||||||
|
if len(fetched) == 0 {
|
||||||
|
if err != nil && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, nats.ErrTimeout) && !errors.Is(err, context.Canceled) {
|
||||||
|
m.sayOnce("the "+oldest.kind+" check queue cannot be taken from", err)
|
||||||
|
}
|
||||||
|
m.dropLetThrough(ctx)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// Another holder took the one that was oldest in the moment between the read and the fetch: what came is a younger check,
|
||||||
|
// handed back and the key released, never run during a delivery.
|
||||||
|
if askedAtOf(fetched[0].Data, "").Add(m.letThroughAfter()).After(time.Now()) {
|
||||||
|
_ = fetched[0].NakWithDelay(time.Minute) // a delay keeps it off the queue of redeliveries a fetch reads first
|
||||||
|
m.dropLetThrough(ctx)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
m.letKey = true
|
||||||
|
return fetched[0], oldest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *natsMachine) letThroughAfter() time.Duration {
|
||||||
|
if m.opts.LetThroughAfter > 0 {
|
||||||
|
return m.opts.LetThroughAfter
|
||||||
|
}
|
||||||
|
return LetThroughAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropLetThrough deletes the let-through key this holder created.
|
||||||
|
func (m *natsMachine) dropLetThrough(ctx context.Context) {
|
||||||
|
m.letKey = false
|
||||||
|
if err := m.gateOf().DropLetThrough(context.WithoutCancel(ctx)); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "could not take the let-through key away; it ages out: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepLetThrough refreshes the key at every heartbeat while the check it was created for runs, and returns the end of that: it
|
||||||
|
// stops the refreshing and deletes the key.
|
||||||
|
func (m *natsMachine) keepLetThrough(ctx context.Context, every time.Duration) (end func()) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
finished := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(finished)
|
||||||
|
tick := time.NewTicker(every)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
putting, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||||
|
if err := m.gateOf().KeepLetThrough(putting, m.on); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "could not keep the let-through key: %v\n", err)
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() {
|
||||||
|
close(done)
|
||||||
|
<-finished
|
||||||
|
m.dropLetThrough(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// whyHeld words the plans a check is held for, for the log.
|
||||||
|
func whyHeld(plans []string) string { return strings.Join(plans, ", ") }
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Checks wait while a delivery builds (novox/hq ADR 0483, issues 1687 and 1688): the order the taking loop hands out in, against
|
||||||
|
// fake queues and a fake bus.
|
||||||
|
|
||||||
|
// fakeQueue is a queue the loop fetches from: the asks waiting, handed out in order, and how often it was fetched from.
|
||||||
|
type fakeQueue struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
waiting []*nats.Msg
|
||||||
|
fetched int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeQueue) Fetch(int, ...nats.PullOpt) ([]*nats.Msg, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.fetched++
|
||||||
|
if len(f.waiting) == 0 {
|
||||||
|
return nil, nats.ErrTimeout
|
||||||
|
}
|
||||||
|
m := f.waiting[0]
|
||||||
|
f.waiting = f.waiting[1:]
|
||||||
|
return []*nats.Msg{m}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeQueue) fetches() int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
// askedAgo is a request asked this long ago, as the id says, for a check of this pull request.
|
||||||
|
func askedAgo(ago time.Duration, number int, ready bool) *nats.Msg {
|
||||||
|
id := fmt.Sprintf("build-%d", time.Now().Add(-ago).UnixNano())
|
||||||
|
body, _ := json.Marshal(BuildRequest{ID: id, Repository: "r", Check: &CheckRequest{Owner: "o", Repo: "r", Number: number, Ready: ready}})
|
||||||
|
return &nats.Msg{Data: body}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAsk() *nats.Msg {
|
||||||
|
body, _ := json.Marshal(BuildRequest{ID: fmt.Sprintf("build-%d", time.Now().UnixNano()), Repository: "r"})
|
||||||
|
return &nats.Msg{Data: body}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeGate is the bus as the loop sees it for the hold.
|
||||||
|
type fakeGate struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
plans []string
|
||||||
|
plansErr error
|
||||||
|
letKey bool
|
||||||
|
taken int
|
||||||
|
kept int
|
||||||
|
dropped int
|
||||||
|
oldestFrom map[*takeQueue]*fakeQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *fakeGate) Building(context.Context) ([]string, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
return g.plans, g.plansErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// OldestWaiting reads the first waiting ask of the fake queue without taking it.
|
||||||
|
func (g *fakeGate) OldestWaiting(_ context.Context, q *takeQueue) (time.Time, bool, error) {
|
||||||
|
f := g.oldestFrom[q]
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if len(f.waiting) == 0 {
|
||||||
|
return time.Time{}, false, nil
|
||||||
|
}
|
||||||
|
return askedAtOf(f.waiting[0].Data, ""), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *fakeGate) TakeLetThrough(context.Context, string) (bool, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
if g.letKey {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
g.letKey = true
|
||||||
|
g.taken++
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *fakeGate) KeepLetThrough(context.Context, string) error {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.kept++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *fakeGate) DropLetThrough(context.Context) error {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.letKey = false
|
||||||
|
g.dropped++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *fakeGate) setPlans(plans ...string) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.plans = plans
|
||||||
|
}
|
||||||
|
|
||||||
|
// aMachine is a loop on the build seat with a delivery queue and the two check queues, all fake.
|
||||||
|
func aMachine() (m *natsMachine, builds, ready, other *fakeQueue, gate *fakeGate) {
|
||||||
|
builds, ready, other = &fakeQueue{}, &fakeQueue{}, &fakeQueue{}
|
||||||
|
subs := []*takeQueue{
|
||||||
|
{kind: queueBuilds, pull: builds, bound: true},
|
||||||
|
{kind: queueReady, pull: ready, bound: true},
|
||||||
|
{kind: queueOther, pull: other, bound: true},
|
||||||
|
}
|
||||||
|
gate = &fakeGate{oldestFrom: map[*takeQueue]*fakeQueue{subs[1]: ready, subs[2]: other}}
|
||||||
|
m = &natsMachine{on: "ace", seat: broker.TheBuildSeat, subs: subs, gate: gate}
|
||||||
|
return m, builds, ready, other, gate
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextAsk(t *testing.T, m *natsMachine) (*nats.Msg, *takeQueue) {
|
||||||
|
t.Helper()
|
||||||
|
msg, q, err := m.next(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return msg, q
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1652/AC6, 1687: a waiting check is not taken while a plan key is there, also when the delivery's queue is empty for the moment
|
||||||
|
// (between two tiers).
|
||||||
|
func TestNoCheckIsTakenWhileAPlanBuilds(t *testing.T) {
|
||||||
|
m, builds, ready, other, gate := aMachine()
|
||||||
|
gate.setPlans("plan-1")
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(2*time.Minute, 1, true)}
|
||||||
|
other.waiting = []*nats.Msg{askedAgo(5*time.Minute, 2, false)}
|
||||||
|
// The delivery's queue is empty: between two tiers.
|
||||||
|
if msg, _ := nextAsk(t, m); msg != nil {
|
||||||
|
t.Fatalf("a check was taken while plan-1 builds: %s", msg.Data)
|
||||||
|
}
|
||||||
|
if ready.fetches() != 0 || other.fetches() != 0 {
|
||||||
|
t.Fatalf("the check queues were fetched from while plan-1 builds: ready %d, other %d", ready.fetches(), other.fetches())
|
||||||
|
}
|
||||||
|
if builds.fetches() == 0 {
|
||||||
|
t.Fatal("the delivery's queue was not looked at")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delivery build is still taken first, and the check waits behind it.
|
||||||
|
func TestADeliveryBuildIsTakenWhileAPlanBuilds(t *testing.T) {
|
||||||
|
m, builds, ready, _, gate := aMachine()
|
||||||
|
gate.setPlans("plan-1")
|
||||||
|
builds.waiting = []*nats.Msg{buildAsk()}
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(2*time.Minute, 1, true)}
|
||||||
|
msg, q := nextAsk(t, m)
|
||||||
|
if msg == nil || q.kind != queueBuilds {
|
||||||
|
t.Fatalf("the delivery build was not taken first: %v %v", msg, q)
|
||||||
|
}
|
||||||
|
if ready.fetches() != 0 {
|
||||||
|
t.Fatal("a check queue was fetched from")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is taken once the key is gone (deleted at the phase's end, or expired).
|
||||||
|
func TestACheckIsTakenOnceTheKeyIsGone(t *testing.T) {
|
||||||
|
m, _, ready, _, gate := aMachine()
|
||||||
|
gate.setPlans("plan-1")
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(2*time.Minute, 1, true)}
|
||||||
|
if msg, _ := nextAsk(t, m); msg != nil {
|
||||||
|
t.Fatal("taken while the key is there")
|
||||||
|
}
|
||||||
|
gate.setPlans()
|
||||||
|
msg, q := nextAsk(t, m)
|
||||||
|
if msg == nil || q.kind != queueReady {
|
||||||
|
t.Fatalf("the check was not taken once the key was gone: %v", q)
|
||||||
|
}
|
||||||
|
if m.letKey {
|
||||||
|
t.Fatal("a check taken without a delivery building took the let-through key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1687: a bucket that cannot be read is said and read as empty, so the checks are taken.
|
||||||
|
func TestAnUnreadableBucketHoldsNoCheck(t *testing.T) {
|
||||||
|
m, _, ready, _, gate := aMachine()
|
||||||
|
gate.plansErr = errors.New("permissions violation")
|
||||||
|
gate.plans = []string{"plan-1"}
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(2*time.Minute, 1, true)}
|
||||||
|
msg, q := nextAsk(t, m)
|
||||||
|
if msg == nil || q.kind != queueReady {
|
||||||
|
t.Fatal("a check was held because the bucket could not be read")
|
||||||
|
}
|
||||||
|
if !m.said["the bucket of the plans building cannot be read"] {
|
||||||
|
t.Fatalf("the fault was not said: %v", m.said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1688: a check asked 30 minutes ago is let through while a key is there; the oldest across both queues by ask time, ahead of a
|
||||||
|
// waiting delivery build; a younger one is never taken before it.
|
||||||
|
func TestTheOldestCheckPastTheBoundGoesFirstAcrossBothQueues(t *testing.T) {
|
||||||
|
m, builds, ready, other, gate := aMachine()
|
||||||
|
gate.setPlans("plan-1")
|
||||||
|
builds.waiting = []*nats.Msg{buildAsk()}
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(31*time.Minute, 1, true)}
|
||||||
|
other.waiting = []*nats.Msg{askedAgo(50*time.Minute, 2, false)}
|
||||||
|
msg, q := nextAsk(t, m)
|
||||||
|
if msg == nil || q.kind != queueOther || !strings.Contains(string(msg.Data), `"number":2`) {
|
||||||
|
t.Fatalf("the oldest check (50 minutes, other queue) was not let through first: %v", q)
|
||||||
|
}
|
||||||
|
if !m.letKey || gate.taken != 1 {
|
||||||
|
t.Fatalf("the let-through key was not created for it: held %v, created %d", m.letKey, gate.taken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1688: several checks past the bound go one at a time, oldest first: while the key is there every other holder takes builds.
|
||||||
|
func TestChecksPastTheBoundGoOneAtATimeOldestFirst(t *testing.T) {
|
||||||
|
m, builds, ready, other, gate := aMachine()
|
||||||
|
gate.setPlans("plan-1")
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(33*time.Minute, 1, true), askedAgo(31*time.Minute, 3, true)}
|
||||||
|
other.waiting = []*nats.Msg{askedAgo(40*time.Minute, 2, false)}
|
||||||
|
var order []string
|
||||||
|
for range 3 {
|
||||||
|
msg, q := nextAsk(t, m)
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf("no check let through after %v", order)
|
||||||
|
}
|
||||||
|
var r BuildRequest
|
||||||
|
_ = json.Unmarshal(msg.Data, &r)
|
||||||
|
order = append(order, fmt.Sprint(r.Check.Number, q.kind))
|
||||||
|
// While it runs another holder (the same fake gate, key held) takes builds only.
|
||||||
|
builds.waiting = []*nats.Msg{buildAsk()}
|
||||||
|
other2 := &natsMachine{on: "shanks", seat: broker.TheBuildSeat, subs: m.subs, gate: gate}
|
||||||
|
if next, nq := nextAsk(t, other2); next == nil || nq.kind != queueBuilds {
|
||||||
|
t.Fatalf("a second holder did not take a delivery build while the let-through key is held: %v", nq)
|
||||||
|
}
|
||||||
|
m.dropLetThrough(context.Background())
|
||||||
|
}
|
||||||
|
if want := []string{"2other", "1ready", "3ready"}; strings.Join(order, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("checks went %v, want %v (oldest first, one at a time)", order, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1688: a check younger than the bound is never let through, and no key is created for it.
|
||||||
|
func TestACheckYoungerThanTheBoundIsHeld(t *testing.T) {
|
||||||
|
m, _, ready, _, gate := aMachine()
|
||||||
|
gate.setPlans("plan-1")
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(29*time.Minute, 1, true)}
|
||||||
|
if msg, _ := nextAsk(t, m); msg != nil {
|
||||||
|
t.Fatal("a check of 29 minutes was let through")
|
||||||
|
}
|
||||||
|
if gate.taken != 0 {
|
||||||
|
t.Fatal("a let-through key was created for a young check")
|
||||||
|
}
|
||||||
|
if ready.fetches() != 0 {
|
||||||
|
t.Fatal("the age was read by a fetch; it must be read without taking")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1688: the holder that lost the race for the key, or the check, takes builds only; and a younger check that arrives in the
|
||||||
|
// moment between the read and the fetch is handed back with the key released.
|
||||||
|
func TestAYoungerCheckFetchedInTheRaceIsHandedBackAndTheKeyReleased(t *testing.T) {
|
||||||
|
m, _, ready, _, gate := aMachine()
|
||||||
|
gate.setPlans("plan-1")
|
||||||
|
// The read sees the old check; before the fetch another holder takes it and a young one is first.
|
||||||
|
gate.oldestFrom[m.subs[1]] = &fakeQueue{waiting: []*nats.Msg{askedAgo(40*time.Minute, 1, true)}}
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(time.Minute, 2, true)}
|
||||||
|
if msg, _ := nextAsk(t, m); msg != nil {
|
||||||
|
t.Fatalf("a young check was run during a delivery: %s", msg.Data)
|
||||||
|
}
|
||||||
|
if gate.letKey || m.letKey {
|
||||||
|
t.Fatal("the let-through key was left held")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key of a check let through is refreshed while it runs and deleted when it ends.
|
||||||
|
func TestTheLetThroughKeyLivesAsLongAsTheCheckRuns(t *testing.T) {
|
||||||
|
m, _, ready, _, gate := aMachine()
|
||||||
|
gate.setPlans("plan-1")
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(40*time.Minute, 1, true)}
|
||||||
|
if msg, _ := nextAsk(t, m); msg == nil {
|
||||||
|
t.Fatal("not let through")
|
||||||
|
}
|
||||||
|
end := m.keepLetThrough(context.Background(), 20*time.Millisecond)
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
gate.mu.Lock()
|
||||||
|
held, kept := gate.letKey, gate.kept
|
||||||
|
gate.mu.Unlock()
|
||||||
|
if !held || kept == 0 {
|
||||||
|
t.Errorf("while the check ran the key was held %v and refreshed %d times", held, kept)
|
||||||
|
}
|
||||||
|
end()
|
||||||
|
gate.mu.Lock()
|
||||||
|
defer gate.mu.Unlock()
|
||||||
|
if gate.letKey || m.letKey {
|
||||||
|
t.Error("the key was not deleted when the check ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,40 @@ type Queue struct {
|
|||||||
AckPending int `json:"ack-pending"`
|
AckPending int `json:"ack-pending"`
|
||||||
MaxDeliver int `json:"max-deliver"`
|
MaxDeliver int `json:"max-deliver"`
|
||||||
Asks []QueuedAsk `json:"asks"`
|
Asks []QueuedAsk `json:"asks"`
|
||||||
|
// Hold is why the checks wait: the delivery plans building and the check let through now (ADR 0483, issues 1687 and 1688);
|
||||||
|
// HoldFault says the bucket could not be read, in which case Hold is empty.
|
||||||
|
Hold CheckHold `json:"hold"`
|
||||||
|
HoldFault string `json:"hold-fault,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LetThroughOf says which ask is let through under ADR 0483 clause 3: the check that waited LetThroughAfter or longer and is next
|
||||||
|
// (the oldest waiting check across the two check queues, while a delivery builds), and the check in flight that was let through
|
||||||
|
// (the let-through key names the machine that runs it). Empty when none.
|
||||||
|
func (q Queue) LetThroughOf(now time.Time) (next, running string) {
|
||||||
|
if len(q.Hold.Plans) == 0 && q.Hold.LetThrough == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
var oldest *QueuedAsk
|
||||||
|
for i := range q.Asks {
|
||||||
|
a := &q.Asks[i]
|
||||||
|
if a.State != AskWaiting || a.Request.Check == nil || a.AskedAt.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if oldest == nil || a.AskedAt.Before(oldest.AskedAt) {
|
||||||
|
oldest = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldest != nil && len(q.Hold.Plans) > 0 && now.Sub(oldest.AskedAt) >= LetThroughAfter {
|
||||||
|
next = oldest.ID
|
||||||
|
}
|
||||||
|
if q.Hold.LetThrough != nil {
|
||||||
|
for _, a := range q.Asks {
|
||||||
|
if a.State == AskInFlight && a.Request.Check != nil && a.On == q.Hold.LetThrough.Builder {
|
||||||
|
running = a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next, running
|
||||||
}
|
}
|
||||||
|
|
||||||
// Of is the asks in one state, in queue order.
|
// Of is the asks in one state, in queue order.
|
||||||
@@ -319,6 +353,16 @@ func ReadQueue(ctx context.Context, js *broker.JetStream, seat string) (Queue, e
|
|||||||
for _, r := range reads {
|
for _, r := range reads {
|
||||||
q.Asks = append(q.Asks, ClassifyQueue(r.asks, r.delivered, r.ackPending, heard)...)
|
q.Asks = append(q.Asks, ClassifyQueue(r.asks, r.delivered, r.ackPending, heard)...)
|
||||||
}
|
}
|
||||||
|
// And why the checks wait, for the build seat that has them (ADR 0483 clause 5). A bucket that cannot be read is said by the
|
||||||
|
// queue, which then names no plan.
|
||||||
|
if seat == broker.TheBuildSeat {
|
||||||
|
hold, err := ReadCheckHold(ctx, js.Conn())
|
||||||
|
if err != nil {
|
||||||
|
q.HoldFault = err.Error()
|
||||||
|
} else {
|
||||||
|
q.Hold = hold
|
||||||
|
}
|
||||||
|
}
|
||||||
return q, nil
|
return q, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 1747: a builder never holds a message it does not start. A message the server sends after a fetch gave up lands in that
|
||||||
|
// queue's subscription; while an ask runs, the queues it does not come from are let go of, so such a message is not held
|
||||||
|
// unheartbeated for the whole run, and the queues are bound again before the next fetch.
|
||||||
|
|
||||||
|
func TestARunningAskLetsGoOfTheQueuesItDidNotComeFrom(t *testing.T) {
|
||||||
|
m, builds, _, other, _ := aMachine()
|
||||||
|
// A delivery build reached the builds queue's subscription after its fetch gave up: it sits in the buffer.
|
||||||
|
builds.waiting = []*nats.Msg{buildAsk()}
|
||||||
|
took := m.subs[1] // the running ask came from the ready queue
|
||||||
|
|
||||||
|
m.quiesce(took)
|
||||||
|
|
||||||
|
if !took.bound || took.pull == nil {
|
||||||
|
t.Error("the queue the running ask came from was let go of; its acknowledgements need it")
|
||||||
|
}
|
||||||
|
for _, q := range []*takeQueue{m.subs[0], m.subs[2]} {
|
||||||
|
if q.bound || q.pull != nil || q.sub != nil {
|
||||||
|
t.Errorf("the %s queue is still held while an ask runs", q.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing is fetched from a queue that was let go of: a message in it cannot be taken unheartbeated.
|
||||||
|
if msg, _, _ := m.next(t.Context()); msg != nil {
|
||||||
|
t.Fatalf("a message was taken from a queue that was let go of: %s", msg.Data)
|
||||||
|
}
|
||||||
|
if builds.fetches() != 0 || other.fetches() != 0 {
|
||||||
|
t.Errorf("fetched from a queue that was let go of: builds %d, other %d", builds.fetches(), other.fetches())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the ask is settled the queue it came from is let go of too: a second message the server delivered into its subscription
|
||||||
|
// on a pull request still open is not run after the ask ends (review of 1747).
|
||||||
|
func TestTheQueueTheAskCameFromIsLetGoOfWhenTheAskEnds(t *testing.T) {
|
||||||
|
m, _, ready, _, _ := aMachine()
|
||||||
|
ready.waiting = []*nats.Msg{askedAgo(time.Minute, 2, true)}
|
||||||
|
m.quiesce(nil)
|
||||||
|
for _, q := range m.subs {
|
||||||
|
if q.bound || q.pull != nil {
|
||||||
|
t.Errorf("the %s queue is still held after the ask ended", q.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.subs[0].bound, m.subs[0].pull = true, &fakeQueue{}
|
||||||
|
if msg, _, _ := m.next(t.Context()); msg != nil || ready.fetches() != 0 {
|
||||||
|
t.Fatal("a message held in the kept queue's subscription was taken after the ask ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 07a65927c3620390e191cb6ccc97d3770f284526 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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.20261021173436-07a65927c362
|
# 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